diff --git a/scripts/link-modules.ts b/scripts/link-modules.ts index b9741e0d2..8857146a9 100644 --- a/scripts/link-modules.ts +++ b/scripts/link-modules.ts @@ -5,6 +5,15 @@ import webpackPaths from '../configs/webpack/webpack.paths'; const { srcNodeModulesPath } = webpackPaths; const { appNodeModulesPath } = webpackPaths; +// `lstat` rather than `existsSync`, which follows the link: a symlink whose +// target is gone reads as absent, the guard passes, and `symlinkSync` then +// throws EEXIST on the link itself. Nothing in the repo recovers from that, +// so every later `npm install` fails at postinstall. +const srcLink = fs.lstatSync(srcNodeModulesPath, { throwIfNoEntry: false }); +if (srcLink?.isSymbolicLink() && !fs.existsSync(srcNodeModulesPath)) { + fs.unlinkSync(srcNodeModulesPath); +} + if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); } diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 005146485..2cf65d2c3 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -1,6 +1,6 @@ import { cp } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { CompilerModule } from './compiler-module' import type { ToolchainProperties } from './types' @@ -434,6 +434,16 @@ describe('CompilerModule', () => { let srcDir: string let extractSpy: jest.SpyInstance + /** + * Toolchain invocations only — the compiler and archiver. + * + * The pass also asks arduino-cli for the include path it would use, which + * is an `arduino-cli compile --only-compilation-database` call rather than + * a toolchain one. Filtering keeps these assertions about the compile. + */ + const toolchainCalls = (calls: readonly string[]) => + calls.filter((cmd) => !cmd.includes('--only-compilation-database')) + const cannedProps: ToolchainProperties = { fqbn: 'arduino:avr:uno', properties: { @@ -475,6 +485,146 @@ describe('CompilerModule', () => { ).rejects.toThrow(/no \.cpp sources found under/) }) + /** + * Stand in for arduino-cli's `--only-compilation-database`: write the + * database it would have written, into the `--build-path` it was given. + */ + const writeCompilationDatabase = (cmd: string, includeDirs: readonly string[]) => { + const buildPath = /--build-path\s+(\S+)/.exec(cmd)?.[1] + if (!buildPath) throw new Error('database run was given no --build-path') + fs.mkdirSync(buildPath, { recursive: true }) + fs.writeFileSync( + join(buildPath, 'compile_commands.json'), + JSON.stringify([{ file: 'x.cpp', arguments: ['g++', '-c', ...includeDirs.map((dir) => `-I${dir}`), 'x.cpp'] }]), + 'utf-8', + ) + } + + it('compiles with the include path arduino-cli reports, transitive libraries and all', async () => { + // Discovery is arduino-cli's job: it is transitive (WiFi.h pulls in + // Network.h from a second library) and it preprocesses, so a header + // behind an #ifdef for another architecture costs nothing. Rebuilding + // that here would approximate it and keep missing cases. + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + if (cmd.includes('--only-compilation-database')) { + writeCompilationDatabase(cmd, ['/core/libraries/WiFi/src', '/core/libraries/Network/src']) + } + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + const compileCmd = toolchainCalls(execCalls)[0] + expect(compileCmd).toContain('-I/core/libraries/WiFi/src') + expect(compileCmd).toContain('-I/core/libraries/Network/src') + }) + + it('asks arduino-cli before the TUs leave src/, and offers it every library', async () => { + // Discovery walks the library's own sources for their includes. Run it + // after the stash and it sees an empty src/, resolves nothing, and the + // compile fails on the first library header. + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + fs.mkdirSync(join(buildDir, 'libraries', 'SensorKit', 'src'), { recursive: true }) + + let srcHeldTheTU: boolean | undefined + let databaseCmd = '' + execImpl.current = async (cmd) => { + if (cmd.includes('--only-compilation-database')) { + srcHeldTheTU = fs.existsSync(join(srcDir, 'c_blocks_code.cpp')) + databaseCmd = cmd + writeCompilationDatabase(cmd, []) + } + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + expect(srcHeldTheTU).toBe(true) + // The build tree's own sources, and every library a `.stlib` shipped. + expect(databaseCmd).toContain(`--library ${srcDir}`) + expect(databaseCmd).toContain(`--library ${join(buildDir, 'libraries', 'SensorKit')}`) + }) + + it('compiles the resource libraries into the archive, with path-derived object names', async () => { + // arduino-cli will not build these: it compiles a library only when it + // discovers an include for it, and the one TU that includes them is + // moved out of its view before it runs. Putting that include back into + // the sketch is worse — the library's macros then rewrite unrelated + // code in the sketch's own translation unit. So they are built here, + // as the Runtime v4 Makefile builds the same tree. + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + const sensorKit = join(buildDir, 'libraries', 'SensorKit', 'src') + const displayKit = join(buildDir, 'libraries', 'DisplayKit', 'src') + fs.mkdirSync(join(sensorKit, 'transport'), { recursive: true }) + fs.mkdirSync(displayKit, { recursive: true }) + fs.writeFileSync(join(sensorKit, 'SensorKit.cpp'), '// sensor\n', 'utf-8') + fs.writeFileSync(join(sensorKit, 'transport', 'util.cpp'), '// sensor util\n', 'utf-8') + // Same file name in a second library — a flat object directory would + // have one overwrite the other. + fs.writeFileSync(join(displayKit, 'util.cpp'), '// display util\n', 'utf-8') + fs.writeFileSync(join(sensorKit, 'SensorKit.h'), '#pragma once\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + if (cmd.includes('--only-compilation-database')) writeCompilationDatabase(cmd, []) + return { stdout: '', stderr: '' } + } + + const result = await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + const compiled = toolchainCalls(execCalls).join('\n') + expect(compiled).toContain(join(sensorKit, 'SensorKit.cpp')) + expect(compiled).toContain(join(sensorKit, 'transport', 'util.cpp')) + expect(compiled).toContain(join(displayKit, 'util.cpp')) + + // Distinct objects, and all of them in the archive. + const objectNames = result.objectFiles.map((file) => basename(file)) + expect(objectNames).toContain('SensorKit__src__transport__util.cpp.o') + expect(objectNames).toContain('DisplayKit__src__util.cpp.o') + expect(new Set(objectNames).size).toBe(objectNames.length) + }) + + it('still compiles when arduino-cli cannot produce a database', async () => { + // Falling back to the core/variant/build-tree includes is what the pass + // did before: a TU needing no library still builds, and one that does + // fails naming the header it wanted. + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + if (cmd.includes('--only-compilation-database')) throw new Error('no core installed') + return { stdout: '', stderr: '' } + } + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }), + ).resolves.toBeDefined() + + expect(toolchainCalls(execCalls)[0]).toContain(`-I${srcDir}`) + }) + it('excludes arduino.cpp from the compile set so the board HAL stays with arduino-cli', async () => { fs.writeFileSync(join(srcDir, 'arduino.cpp'), '// HAL\n', 'utf-8') fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') @@ -492,9 +642,10 @@ describe('CompilerModule', () => { handleOutputData: noopLog, }) - // Two compile invocations + one ar invocation = 3 exec calls. - expect(execCalls).toHaveLength(3) - const compileCmds = execCalls.slice(0, 2).join('\n') + // Two compile invocations + one ar invocation = 3 toolchain calls. + const toolchain = toolchainCalls(execCalls) + expect(toolchain).toHaveLength(3) + const compileCmds = toolchain.slice(0, 2).join('\n') expect(compileCmds).toContain('pou_MAIN.cpp') expect(compileCmds).toContain('configuration.cpp') expect(compileCmds).not.toContain('arduino.cpp') diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 27ad9c05c..371d1cb26 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1,13 +1,15 @@ import { spawn } from 'node:child_process' -import crypto, { createHash } from 'node:crypto' +import crypto, { createHash, randomUUID } from 'node:crypto' +import type { Dirent } from 'node:fs' import { existsSync, promises as fs } from 'node:fs' -import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import type { IncomingMessage } from 'node:http' import https from 'node:https' import os from 'node:os' import path from 'node:path' import { join, resolve as pathResolve, sep as pathSep } from 'node:path' +import { HardwareModule } from '@root/backend/editor/hardware' import { RUNTIME_API_PORT } from '@root/backend/editor/runtime/runtime-api-client' import { resolveTrustedKeysArtifact } from '@root/backend/shared/compile/steps/generate-trusted-keys' import type { VppModbusScreenState } from '@root/backend/shared/compile/steps/modbus-defines' @@ -24,6 +26,7 @@ import { runWithConcurrencyLimit } from './run-with-concurrency' type StrucppCompileError = import('strucpp').CompileError import { buildArduinoCliCompileArgs } from '@root/backend/shared/firmware/build-arduino-cli-args' +import { projectAndLibraryTypeNames } from '@root/backend/shared/library/inject-library-blocks' import { runLibraryBuildPipeline } from '@root/backend/shared/library/library-build-orchestrator' import { parseNativePouRefs } from '@root/backend/shared/library/native-pou-list' import { buildKnownPous, emitCompileErrorEvents } from '@root/backend/shared/library/program-build-helpers' @@ -35,6 +38,12 @@ import { transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' import type { KnownPou } from '@root/backend/shared/utils/PLC/split-program-st' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { + pickVerifyBoard, + SIMULATOR_BOARD, + SIMULATOR_CORE, +} from '@root/middleware/shared/utils/library/pick-verify-board' /** * Shared bridge contract between `compileLibrary` and its inner @@ -94,6 +103,9 @@ type ProjectDataWithCppPous = PLCProjectData & { const POST_BUILD_START_TIMEOUT_MS = 5000 const POST_BUILD_START_POLL_INTERVAL_MS = 150 +/** The runtime a `build.verify: "runtime"` target resolves to. */ +const RUNTIME_V4_BOARD = 'OpenPLC Runtime v4' + import { assertPathContained } from '@root/backend/editor/utils/path-containment' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' import { runCompilePipeline } from '@root/backend/shared/compile/pipeline' @@ -122,6 +134,7 @@ import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' +import type { AvailableBoards } from '../hardware/types' import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' @@ -401,21 +414,193 @@ class CompilerModule { } /** - * Resolve a board target to the arduino-cli core ID - * (`arduino-cli core install` target — e.g. `arduino:avr`). + * Absolute paths to the library folders the firmware bundle materialised + * under `/libraries/`. arduino-cli compiles a library's + * `src/` only when the folder is named with its own `--library`, so each + * has to reach the command line. + */ + /** + * The `-I` flags arduino-cli itself would use for this sketch. + * + * The pre-compile pass invokes the toolchain directly, so it never gets + * arduino-cli's library discovery — and that discovery is not something to + * reimplement. It is transitive (`WiFi.h` pulls `Network.h` from a second + * library), it honours `depends=` in `library.properties`, and it resolves + * conditional includes by preprocessing, so a header behind `#ifdef ESP8266` + * costs nothing on an ESP32. Every hand-rolled approximation of that gets + * one layer further and stops. + * + * So the answer is asked for rather than guessed: `--only-compilation-database` + * runs discovery and writes the command line it would have used, without + * compiling anything. This MUST run before the TUs are stashed out of `src/` + * — discovery walks the library's sources, and a stashed file's `#include` + * is a file arduino-cli never sees. + * + * Returns `[]` when the database cannot be produced. The pass then compiles + * with the core, variant and build-tree includes alone, which is what it did + * before — a TU needing no library still builds, and one that does fails + * naming the header it wanted. + */ + async #discoverIncludeFlags({ + fqbn, + sketchPath, + libraryPaths, + }: { + fqbn: string + sketchPath: string + libraryPaths: readonly string[] + }): Promise { + let binaryPath = this.arduinoCliBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') binaryPath += '.exe' + + // A build path of its own: the database run must not disturb the tree the + // real compile is about to use. + const databasePath = join(os.tmpdir(), `openplc-cdb-${randomUUID()}`) + try { + await execRecipeArgv( + [ + binaryPath, + 'compile', + '--fqbn', + fqbn, + '--only-compilation-database', + '--build-path', + databasePath, + ...libraryPaths.flatMap((libraryPath) => ['--library', libraryPath]), + sketchPath, + ...this.arduinoCliBaseParameters, + ], + { maxBuffer: 16 * 1024 * 1024 }, + ) + + const raw = await readFile(join(databasePath, 'compile_commands.json'), 'utf-8') + const entries = JSON.parse(raw) as Array<{ arguments?: string[]; command?: string }> + + // Order is preserved and duplicates dropped: arduino-cli emits the same + // include set per TU, and `-I` order decides which of two same-named + // headers wins. + const seen = new Set() + const flags: string[] = [] + for (const entry of entries) { + for (const token of entry.arguments ?? entry.command?.split(/\s+/) ?? []) { + if (!token.startsWith('-I') || token.length === 2) continue + if (seen.has(token)) continue + seen.add(token) + flags.push(token) + } + } + return flags + } catch { + return [] + } finally { + await rm(databasePath, { recursive: true, force: true }).catch(() => undefined) + } + } + + /** + * Every `.cpp` a resource library ships, with the object name each will take. + * + * A `.stlib` carries the C/C++ libraries its blocks compile against, and + * something has to build them. arduino-cli will not: it compiles a library + * only when it discovers an include for it, and the one translation unit + * that includes these is moved out of its view before it runs. Putting that + * include back into the sketch is worse than the disease — a library's + * macros then land in the sketch's own translation unit, where an + * object-like `#define` silently rewrites an enum constant of the same name + * in unrelated code. + * + * So the pre-compile pass builds them, exactly as the Runtime v4 Makefile + * does for the same archive: it finds every `.cpp` under the generated tree, + * which sweeps up each resource library's sources. Both consumers compile + * the resource tree themselves rather than asking a build system to infer + * it. + * + * Object names are derived from the path below `libraries/`, not the file + * name: two libraries may each ship a `util.cpp`, and a flat object + * directory would have the second overwrite the first. + */ + async #resourceLibrarySources(compilationPath: string): Promise> { + const root = join(compilationPath, 'libraries') + const found: Array<{ sourcePath: string; objectName: string }> = [] + + const walk = async (dir: string): Promise => { + let entries: Dirent[] + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + // Symlinks are not followed: the archive must hold what the library + // shipped, not whatever a link happened to point at. + if (entry.isSymbolicLink()) continue + const full = join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full) + } else if (entry.name.endsWith('.cpp')) { + const objectName = path.relative(root, full).split(path.sep).join('__') + found.push({ sourcePath: full, objectName }) + } + } + } + + await walk(root) + return found.sort((a, b) => a.objectName.localeCompare(b.objectName)) + } + + async #resourceLibraryDirs(compilationPath: string): Promise { + const root = join(compilationPath, 'libraries') + try { + const entries = await readdir(root, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(root, entry.name)) + .sort() + } catch { + return [] + } + } + + /** + * The board a library's verify target compiles against. + * + * A target names a core, not a board, because that is what a library + * targets — but arduino-cli needs an FQBN, so one installed board of that + * core stands in for it. `pickVerifyBoard` owns that choice, shared with + * Build Settings so the screen names the board the build will use. * - * Single source of truth: reads from the shared - * `backend/shared/firmware/hals.json` bundle, the same file the - * renderer's `bridge.getAvailableBoards()` exposes via - * `boardInfo.core`. - * Used internally by the library-project verification path so a - * future hals.json edit (rename, new board, version bump) - * propagates to verification automatically — without any code - * change here. + * Falls back to the simulator when the target names no core, or names one + * with no board installed: verification is advisory and the `.stlib` still + * builds, so this warns rather than fails. */ - async #getBoardCore(board: string): Promise { - const halsFileContent = await readHalsFile() - return halsFileContent[board]?.['core'] ?? null + async #resolveVerifyBoard( + target: LibraryVerifyTarget, + warn: (message: string) => void, + ): Promise<{ board: string; core: string | null }> { + if (target.mode === 'runtime') { + return { board: RUNTIME_V4_BOARD, core: null } + } + if (!target.core) { + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + + let boards: AvailableBoards + try { + boards = await new HardwareModule().getAvailableBoards() + } catch (error) { + warn(`Could not read the board catalogue (${getErrorMessage(error)}) — verifying against ${SIMULATOR_BOARD}.`) + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + + const chosen = pickVerifyBoard( + [...boards.entries()].map(([name, info]) => ({ name, core: info.core, compiler: info.compiler })), + target.core, + ) + if (!chosen) { + warn(`No board is installed for core "${target.core}" — verifying against ${SIMULATOR_BOARD}.`) + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + return { board: chosen, core: target.core } } /** @@ -1247,6 +1432,12 @@ class CompilerModule { projectData: ProjectDataWithCppPous, sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, + /** Every data type in scope — the project's own and the enabled libraries'. + * Tells a structure or enumeration, which strucpp aliases as `IEC_`, + * from a function block instance, a bare `class `; the variable + * alone cannot say. Built by `projectAndLibraryTypeNames`, which is why it + * is passed rather than derived: only the caller holds the archives. */ + typeNames: string[], ) { const originalCppPous = projectData.originalCppPous || [] @@ -1260,11 +1451,7 @@ class CompilerModule { variables: pou.variables, })) as CppPouDataHeader[] - // The project's data-type names let the generator tell a structure or - // enumeration (which strucpp aliases as `IEC_`) from a function block - // instance (a bare `class `), which the variable alone cannot say. - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const headerContent: string = generateCBlocksHeader(cppPous, userTypeNames) + const headerContent: string = generateCBlocksHeader(cppPous, typeNames) const headerFilePath = join(sourceTargetFolderPath, 'c_blocks.h') try { @@ -1284,6 +1471,12 @@ class CompilerModule { // future runtime might branch off this discriminator again. _boardRuntime: string, handleOutputData: HandleOutputDataCallback, + /** Every data type in scope — the project's own and the enabled libraries'. + * Tells a structure or enumeration, which strucpp aliases as `IEC_`, + * from a function block instance, a bare `class `; the variable + * alone cannot say. Built by `projectAndLibraryTypeNames`, which is why it + * is passed rather than derived: only the caller holds the archives. */ + typeNames: string[], ) { const originalCppPous = projectData.originalCppPous || [] @@ -1297,10 +1490,7 @@ class CompilerModule { // -std=gnu++17. The static Baremetal/c_blocks_code.cpp baseline stays // strucpp-free and is compiled by arduino-cli in the core's native // standard. - // Every data type the project declares is aliased into the block's scope, - // including ones reachable only through a structure member. - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const codeContent = generateCBlocksCode(cppPous, userTypeNames) + const codeContent = generateCBlocksCode(cppPous, typeNames) const codeFilePath = join(compilationPath, 'src', 'c_blocks_code.cpp') try { @@ -1418,7 +1608,11 @@ class CompilerModule { fqbn: string extraCxxFlags?: string[] handleOutputData: HandleOutputDataCallback - }): Promise<{ archivePath: string; archCandidates: string[]; objectFiles: string[] }> { + }): Promise<{ + archivePath: string + archCandidates: string[] + objectFiles: string[] + }> { const tcProps = await this.extractToolchainProperties(fqbn) const srcDir = join(compilationPath, 'src') @@ -1426,6 +1620,17 @@ class CompilerModule { const sourcesStash = join(compilationPath, 'precompile', 'sources') const objDir = join(compilationPath, 'precompile', 'obj') + // Ask arduino-cli what it would put on the include path, BEFORE the TUs + // leave `src/`. Its discovery walks the library's sources, so a file + // already stashed is one it never sees — and its answer is what makes a + // block's `#include ` resolve here, transitive dependencies and + // all. See `#discoverIncludeFlags`. + const discoveredIncludes = await this.#discoverIncludeFlags({ + fqbn, + sketchPath: baremetalDir, + libraryPaths: [srcDir, join(srcDir, 'lib'), ...(await this.#resourceLibraryDirs(compilationPath))], + }) + // Stash strucpp-emitted .cpp out of src/ BEFORE compile, then read the // stash to discover the TU set. Two reasons: // @@ -1460,6 +1665,9 @@ class CompilerModule { const stashEntries = (await readdir(sourcesStash)).filter((name) => name.endsWith('.cpp')).sort() const sources = stashEntries.map((name) => join(sourcesStash, name)) + // The resource libraries build here too — see `#resourceLibrarySources`. + const resourceSources = await this.#resourceLibrarySources(compilationPath) + if (sources.length === 0) { throw new Error(`handlePrecompileUserLib: no .cpp sources found under ${srcDir} or ${sourcesStash}`) } @@ -1514,6 +1722,7 @@ class CompilerModule { ...(variantPath ? [`-I${variantPath}`] : []), `-I${srcDir}`, `-I${baremetalDir}`, + ...discoveredIncludes, ] const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraNonIncludeFlags] @@ -1548,7 +1757,11 @@ class CompilerModule { // pou_MAIN 457), which overflowed the segment and failed the link with // "section `.text1' will not fit in region `iram1_0_seg'" — a message that // names neither this archive nor the reason. - const objectFiles = sources.map((sourcePath) => join(objDir, `${path.basename(sourcePath)}.o`)) + const compileUnits = [ + ...sources.map((sourcePath) => ({ sourcePath, objectName: path.basename(sourcePath) })), + ...resourceSources, + ] + const objectFiles = compileUnits.map(({ objectName }) => join(objDir, `${objectName}.o`)) // Cap concurrent toolchain spawns at the host's logical core count. // An unbounded `sources.map(async …)` was dispatching one g++ per TU @@ -1559,7 +1772,7 @@ class CompilerModule { // covers environments where `os.cpus()` reports zero. const compileConcurrency = os.cpus().length - await runWithConcurrencyLimit(sources, compileConcurrency, async (sourcePath, idx) => { + await runWithConcurrencyLimit(compileUnits, compileConcurrency, async ({ sourcePath }, idx) => { const objectPath = objectFiles[idx] const argv = [ @@ -1816,6 +2029,7 @@ class CompilerModule { ...buildArduinoCliCompileArgs(compileEntry, { sketchPath: join(baremetalPath, 'Baremetal.ino'), libraryPath: join(compilationPath, 'src'), + resourceLibraryPaths: await this.#resourceLibraryDirs(compilationPath), avrLibStdCppInclude, cleanBuild, }), @@ -3224,13 +3438,18 @@ class CompilerModule { return } + // Resolved once, outside the compile step: the C-blocks header and code + // below need the same archives, to spell a pin typed by a library's own + // data type the way strucpp declared it. + const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) + const { archives: libraries, missing: missingLibraries } = + mainProcessBridge.loadEnabledArchives(enabledLibraryNames) + const typeNames = projectAndLibraryTypeNames(projectData, libraries) + // Compile ST to C++ with STruC++ (replaces iec2c + debug + glue generation) try { const hasCBlocks = ((projectData as ProjectDataWithCppPous).originalCppPous?.length ?? 0) > 0 const knownPous = buildKnownPous(projectData.pous) - const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) - const { archives: libraries, missing: missingLibraries } = - mainProcessBridge.loadEnabledArchives(enabledLibraryNames) await this.handleCompileSTtoCpp( sourceTargetFolderPath, (data, logLevel, compileError) => { @@ -3257,9 +3476,14 @@ class CompilerModule { // Generate C/C++ blocks header file try { - await this.handleGenerateCBlocksHeader(projectData, sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) + await this.handleGenerateCBlocksHeader( + projectData, + sourceTargetFolderPath, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + typeNames, + ) } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', @@ -3275,9 +3499,15 @@ class CompilerModule { // Generate C/C++ blocks code file try { - await this.handleGenerateCBlocksCode(projectData, compilationPath, boardRuntime, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) + await this.handleGenerateCBlocksCode( + projectData, + compilationPath, + boardRuntime, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + typeNames, + ) } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', @@ -3326,13 +3556,13 @@ class CompilerModule { * 5. Write the archive (same `JSON.stringify(archive, null, 2)` * shape `library-manager-module` persists user-installed * archives with) to `/build/.stlib`. - * 6. (Phase 8) Run an end-to-end avr-gcc verification compile - * against the OpenPLC Simulator target, gated by an MD5 - * cache keyed off the produced program.st. Verification - * failures surface as warnings on `result.verification`, - * never as build errors — a legitimate user target may have - * more memory than the AVR simulator. `cleanBuild` skips - * the cache and forces a re-verification. + * 6. Run an end-to-end verification compile against the target the + * manifest's `build` block names, gated by an MD5 cache keyed off + * the verified sources and that target. Verification failures + * surface as warnings on `result.verification`, never as build + * errors — the `.stlib` carries source and the consumer compiles it + * for its own board. `cleanBuild` skips the cache and forces a + * re-verification. */ async compileLibrary( args: Array, @@ -3371,8 +3601,8 @@ class CompilerModule { // the shared orchestrator from here on. const libraryPort = createDesktopLibraryBuildPort({ loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), - runVerificationCompile: ({ projectPath: p, verifyProjectData: v, emit }) => - this.runVerificationCompile(p, v as PLCProjectData, mainProcessBridge, (message, logLevel) => + runVerificationCompile: ({ projectPath: p, verifyProjectData: v, target, emit }) => + this.runVerificationCompile(p, v as PLCProjectData, target, mainProcessBridge, (message, logLevel) => emit(message, logLevel), ), }) @@ -3397,9 +3627,9 @@ class CompilerModule { /** * Run an end-to-end verification compile of a synthetic Library - * Project against the OpenPLC Simulator target. Reuses the full - * `compileProgram` pipeline (strucpp → arduino-cli → bundled - * avr-gcc) by feeding it a private `MessageChannelMain` — verifies + * Project against the manifest's verify target. Reuses the full + * `compileProgram` pipeline (strucpp → arduino-cli → the core's + * toolchain) by feeding it a private `MessageChannelMain` — verifies * the same way the program build does, against the same binaries, * with zero code duplication. * @@ -3422,17 +3652,17 @@ class CompilerModule { private async runVerificationCompile( projectPath: string, verifyData: PLCProjectData, + target: LibraryVerifyTarget, bridge: LibraryVerificationBridge, forwardLog: (message: string, logLevel?: 'info' | 'warning' | 'error') => void, ): Promise<{ success: boolean; message?: string }> { - // Look up the simulator board's core ID from `hals.json` — - // single source of truth shared with the renderer-side - // `boardInfo.core` lookup. Falls back to a sensible default - // only if hals.json has been mangled; the resulting compile - // would fail at `core install` and surface as a verification - // warning, which is the documented advisory behaviour. - const SIMULATOR_BOARD = 'OpenPLC Simulator' - const boardCore = (await this.#getBoardCore(SIMULATOR_BOARD)) ?? 'arduino:avr' + const { board, core: boardCore } = await this.#resolveVerifyBoard(target, (message) => + forwardLog(message, 'warning'), + ) + // Name the board, not just the core: which board stands in for a core + // decides the FQBN and the defines, so a compile error that only that + // board produces is otherwise unattributable. + forwardLog(`Verifying against ${board}${boardCore ? ` (${boardCore})` : ''}.`, 'info') return new Promise((resolve) => { const channel = new MessageChannelMain() @@ -3496,7 +3726,7 @@ class CompilerModule { // values the inner `compileProgram` re-casts off `args as [...]`, const compileArgs: Array = [ projectPath, - SIMULATOR_BOARD, + board, boardCore, true, verifyData, diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index d1afb3504..b4f2bd204 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -31,7 +31,7 @@ import { transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' import type { TranspileToStArgs, TranspileToStResult } from '@root/middleware/shared/ports/compiler-platform-port' -import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-build-port' +import type { LibraryBuildPort, LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' /** * Subset of the desktop CompilerModule that the port leans on. @@ -49,15 +49,15 @@ export interface DesktopLibraryBuildPortDeps { loadEnabledArchives(enabledNames: string[]): { archives: unknown[]; missing: string[] } /** - * Run a verification compile against the OpenPLC Simulator board. - * Wraps `CompilerModule.runVerificationCompile` so the port stays - * decoupled from the compiler module's full surface. Failures - * here are advisory — caller surfaces them as warnings, never as - * a fatal build error. + * Run a verification compile against `target`. Wraps + * `CompilerModule.runVerificationCompile` so the port stays decoupled + * from the compiler module's full surface. Failures here are advisory — + * caller surfaces them as warnings, never as a fatal build error. */ runVerificationCompile(args: { projectPath: string verifyProjectData: unknown + target: LibraryVerifyTarget emit: (message: string, level?: 'info' | 'warning' | 'error') => void }): Promise<{ success: boolean; message?: string }> } @@ -109,12 +109,66 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) } }, + async readBuildFileBase64(projectPath: string, relPath: string): Promise { + const fullPath = resolveProjectRelativePath(projectPath, relPath) + try { + return (await fs.readFile(fullPath)).toString('base64') + } catch (error) { + if (isFsNotFound(error)) return null + throw error + } + }, + + async listProjectDirs(projectPath: string, relPath: string): Promise { + const root = resolveProjectRelativePath(projectPath, relPath) + let entries + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch (error) { + if (isFsNotFound(error)) return [] + throw error + } + // Symlinks are not followed here for the same reason they are not + // followed when walking: a link out of the tree would put files the + // author never chose into a published archive. + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + }, + async writeBuildFile(projectPath: string, relPath: string, content: string): Promise { const fullPath = resolveProjectRelativePath(projectPath, relPath) await fs.mkdir(path.dirname(fullPath), { recursive: true }) await fs.writeFile(fullPath, content, 'utf-8') }, + async listProjectFiles(projectPath: string, relPath: string): Promise { + const root = resolveProjectRelativePath(projectPath, relPath) + const walk = async (dir: string, prefix: string): Promise => { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch (error) { + if (isFsNotFound(error)) return [] + throw error + } + const found: string[] = [] + for (const entry of entries) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + // Symlinks are not followed: a link out of the tree would put + // arbitrary files into a published archive. + if (entry.isDirectory()) { + found.push(...(await walk(path.join(dir, entry.name), rel))) + } else if (entry.isFile()) { + found.push(rel) + } + } + return found + } + return (await walk(root, '')).sort() + }, + async deleteBuildSubtree(projectPath: string, relPath: string): Promise { const fullPath = resolveProjectRelativePath(projectPath, relPath) // `force: true` makes the call a no-op when the subtree is @@ -130,10 +184,11 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) return Promise.resolve(deps.loadEnabledArchives(projectLibraryRefs.map((r) => r.name))) }, - async verifyCompile({ projectPath, verifyProjectData, emit }) { + async verifyCompile({ projectPath, verifyProjectData, target, emit }) { return deps.runVerificationCompile({ projectPath, verifyProjectData, + target, // `runVerificationCompile` forwards every line off // `compileProgram`'s message port; pass them straight // through to the orchestrator's emit. diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index f365af655..0505046c3 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -52,6 +52,8 @@ import type { UploadRuntimeV3Args, UploadRuntimeV4Args, } from '@root/middleware/shared/ports/compiler-platform-port' +import type { BundleFile } from '@root/middleware/shared/utils/library/bundle-file' +import { isBinaryBundleFile } from '@root/middleware/shared/utils/library/bundle-file' import { createHash } from 'crypto' import { promises as fs } from 'fs' import { dirname, join } from 'path' @@ -64,6 +66,26 @@ import type { CompilerModule } from './compiler-module' * port stays free of the wider class surface (logging internals, * file-watching, etc.). */ + +/** + * Write one bundle entry, creating its parent directories. + * + * A bundle is nearly all generated text, but a library that declares + * `precompiled=true` ships a `.a`, which reaches here base64-encoded because + * the archive it travelled in is JSON. Writing that as text would produce a + * file the linker rejects, with nothing in the message pointing back here. + */ +async function writeBundleFile(absPath: string, file: BundleFile): Promise { + await fs.mkdir(dirname(absPath), { recursive: true }) + if (isBinaryBundleFile(file)) { + // Copied into a plain `Uint8Array`: `Buffer` and the `Uint8Array` this + // TypeScript lib expects are not assignable to one another. + await fs.writeFile(absPath, new Uint8Array(Buffer.from(file.base64, 'base64'))) + return + } + await fs.writeFile(absPath, file, 'utf-8') +} + export interface EditorCompilerHandlers { handleCompileArduinoProgram: CompilerModule['handleCompileArduinoProgram'] handleUploadProgram: CompilerModule['handleUploadProgram'] @@ -290,11 +312,9 @@ export function createEditorCompilerPlatformPort( // compile pipeline; doing it here once preserves the same // on-disk layout arduino-cli expects. await Promise.all( - Object.entries(args.files).map(async ([relPath, content]) => { - const absPath = join(context.compilationPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - await fs.writeFile(absPath, content, 'utf-8') - }), + Object.entries(args.files).map(async ([relPath, content]) => + writeBundleFile(join(context.compilationPath, relPath), content), + ), ) // Invoke the existing handler — it spawns arduino-cli compile @@ -590,13 +610,11 @@ export function createEditorCompilerPlatformPort( log: PlatformLog, ): Promise { try { - const entries: Array<[string, string]> = Object.entries(args.bundle) + const entries: Array<[string, BundleFile]> = Object.entries(args.bundle) await Promise.all( - entries.map(async ([relPath, content]: [string, string]) => { - const absPath = join(context.sourceTargetFolderPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - await fs.writeFile(absPath, content, 'utf-8') - }), + entries.map(async ([relPath, content]: [string, BundleFile]) => + writeBundleFile(join(context.sourceTargetFolderPath, relPath), content), + ), ) return { written: entries.length } } catch (error) { diff --git a/src/backend/editor/services/index.ts b/src/backend/editor/services/index.ts index ec16c292b..de63db9fe 100644 --- a/src/backend/editor/services/index.ts +++ b/src/backend/editor/services/index.ts @@ -1,3 +1,4 @@ +export * from './library-resources-service' export * from './logger-service' export * from './pou-service' export * from './project-service' diff --git a/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts b/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts new file mode 100644 index 000000000..1295f361e --- /dev/null +++ b/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts @@ -0,0 +1,167 @@ +/** + * `resources/` management, against a real temp filesystem — the guarantees + * here are about what lands on disk, so stubbing `fs` would test nothing. + */ + +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { addLibraryResource, listLibraryResources, removeLibraryResource } from '..' + +/** + * Creating a symlink needs Developer Mode or elevation on Windows. The editor + * ships there, so the symlink case skips where the platform refuses rather + * than failing — a red suite on Windows would be noise, not a finding. + */ +const canCreateSymlinks = ((): boolean => { + const probe = mkdtempSync(join(tmpdir(), 'symlink-probe-')) + try { + writeFileSync(join(probe, 'target'), '') + symlinkSync(join(probe, 'target'), join(probe, 'link')) + return true + } catch { + return false + } finally { + rmSync(probe, { recursive: true, force: true }) + } +})() +const itWithSymlinks = canCreateSymlinks ? it : it.skip + +let projectPath: string +let sourceRoot: string + +/** A minimal Arduino library folder: `library.properties` beside `src/`. */ +function makeLibrary(root: string, name: string): string { + const dir = join(root, name) + mkdirSync(join(dir, 'src', 'transport'), { recursive: true }) + writeFileSync(join(dir, 'library.properties'), `name=${name}\nversion=1.0.0\n`) + writeFileSync(join(dir, 'src', `${name}.h`), '#pragma once\n') + writeFileSync(join(dir, 'src', 'transport', 'udp.cpp'), '// udp\n') + return dir +} + +beforeEach(() => { + projectPath = mkdtempSync(join(tmpdir(), 'library-project-')) + sourceRoot = mkdtempSync(join(tmpdir(), 'library-source-')) +}) + +afterEach(() => { + rmSync(projectPath, { recursive: true, force: true }) + rmSync(sourceRoot, { recursive: true, force: true }) +}) + +describe('listLibraryResources', () => { + it('returns nothing when the project has no resources directory', async () => { + expect(await listLibraryResources(projectPath)).toEqual([]) + }) + + it('lists each folder with its files, relative and sorted', async () => { + makeLibrary(join(projectPath, 'resources'), 'SensorKit') + expect(await listLibraryResources(projectPath)).toEqual([ + { name: 'SensorKit', files: ['library.properties', 'src/SensorKit.h', 'src/transport/udp.cpp'] }, + ]) + }) + + it('ignores loose files: they belong to no library and the build skips them', async () => { + mkdirSync(join(projectPath, 'resources'), { recursive: true }) + writeFileSync(join(projectPath, 'resources', 'README.md'), '# Resources\n') + makeLibrary(join(projectPath, 'resources'), 'SensorKit') + expect((await listLibraryResources(projectPath)).map((f) => f.name)).toEqual(['SensorKit']) + }) +}) + +describe('addLibraryResource', () => { + it('copies the folder in under its own name, structure intact', async () => { + const source = makeLibrary(sourceRoot, 'DisplayKit') + const result = await addLibraryResource(projectPath, source) + + expect(result.success).toBe(true) + expect(result.folder).toEqual({ + name: 'DisplayKit', + files: ['library.properties', 'src/DisplayKit.h', 'src/transport/udp.cpp'], + }) + expect(await listLibraryResources(projectPath)).toHaveLength(1) + }) + + it('refuses rather than merges when the name is already taken', async () => { + const source = makeLibrary(sourceRoot, 'SensorKit') + await addLibraryResource(projectPath, source) + // Merging would silently lose edits the author made in place. + const second = await addLibraryResource(projectPath, source) + expect(second.success).toBe(false) + expect(second.error).toMatch(/already in resources/) + }) + + it('takes only library.properties and src/, whatever else the folder holds', async () => { + // The author points this at a checkout, not a curated directory. A real + // one measured 2725 files, 43 of which were the library. + const source = makeLibrary(sourceRoot, 'SensorKit') + for (const dir of ['.git', 'node_modules', 'build', 'build-asan', 'docker', 'docs', 'examples', 'test']) { + mkdirSync(join(source, dir), { recursive: true }) + writeFileSync(join(source, dir, 'thing'), 'not part of the library\n') + } + writeFileSync(join(source, 'CMakeLists.txt'), 'project(demo)\n') + writeFileSync(join(source, 'README.md'), '# demo\n') + + const result = await addLibraryResource(projectPath, source) + expect(result.success).toBe(true) + expect(result.folder?.files).toEqual(['library.properties', 'src/SensorKit.h', 'src/transport/udp.cpp']) + }) + + it('carries a precompiled binary that sits under src/', async () => { + // `precompiled=true` libraries ship a `.a` beside their headers, and it is + // as much part of the library as they are. + const source = makeLibrary(sourceRoot, 'SensorKit') + mkdirSync(join(source, 'src', 'esp32'), { recursive: true }) + writeFileSync(join(source, 'src', 'esp32', 'libsensor.a'), new Uint8Array([0, 1, 2, 255])) + + const result = await addLibraryResource(projectPath, source) + expect(result.success).toBe(true) + expect(result.folder?.files).toContain('src/esp32/libsensor.a') + }) + + it('refuses a folder that is not a library', async () => { + // Copying it in would land an empty library whose failure surfaces at the + // consumer's link step, a long way from the folder that caused it. + const source = join(sourceRoot, 'NotALibrary') + mkdirSync(join(source, 'include'), { recursive: true }) + writeFileSync(join(source, 'include', 'thing.h'), '#pragma once\n') + + const result = await addLibraryResource(projectPath, source) + expect(result.success).toBe(false) + expect(result.error).toMatch(/library\.properties/) + expect(result.error).toMatch(/src\//) + }) + + itWithSymlinks('does not follow a symlink out of the tree', async () => { + const source = makeLibrary(sourceRoot, 'SensorKit') + const outside = join(sourceRoot, 'outside.txt') + writeFileSync(outside, 'secret\n') + symlinkSync(outside, join(source, 'link.txt')) + + const result = await addLibraryResource(projectPath, source) + expect(result.success).toBe(true) + // The link is copied as a link, so the file it points at is not published. + expect(await listLibraryResources(projectPath)).toEqual([ + { name: 'SensorKit', files: ['library.properties', 'src/SensorKit.h', 'src/transport/udp.cpp'] }, + ]) + }) +}) + +describe('removeLibraryResource', () => { + it('removes the folder', async () => { + await addLibraryResource(projectPath, makeLibrary(sourceRoot, 'SensorKit')) + expect(await removeLibraryResource(projectPath, 'SensorKit')).toEqual({ success: true }) + expect(await listLibraryResources(projectPath)).toEqual([]) + }) + + it('refuses a name that would escape resources/', async () => { + makeLibrary(join(projectPath, 'resources'), 'SensorKit') + for (const name of ['..', '../..', 'a/b', '/etc']) { + const result = await removeLibraryResource(projectPath, name) + expect(result.success).toBe(false) + } + expect(await listLibraryResources(projectPath)).toHaveLength(1) + }) +}) diff --git a/src/backend/editor/services/library-resources-service/index.ts b/src/backend/editor/services/library-resources-service/index.ts new file mode 100644 index 000000000..ca88b6a0c --- /dev/null +++ b/src/backend/editor/services/library-resources-service/index.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * A library project's `resources/` directory, as the Build Settings dialog + * manages it. + * + * `resources/` holds one folder per C/C++ library the project's blocks + * compile against, each laid out the ordinary Arduino way + * (`library.properties` beside `src/`). The build packages every folder into + * the `.stlib` verbatim and both consumers — arduino-cli and the runtime + * Makefile — resolve each as a library. See + * `library-build-orchestrator.readResources` for the read side. + * + * The editor writes here so the author does not have to manage the directory + * on disk. Everything below takes an absolute project path the caller has + * already checked against the open project. + */ + +import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises' +import { basename, join, relative, sep } from 'node:path' + +import { isSafeRelativePath } from '@root/backend/shared/utils/path-safety' +import { + isLibraryDir, + isLibraryFile, + LIBRARY_FOLDER_RULE, + LIBRARY_PROPERTIES, + LIBRARY_SRC_DIR, +} from '@root/middleware/shared/utils/library/library-folder' + +import { assertPathContained } from '../../utils/path-containment' + +/** Project-relative directory the folders live in. */ +const RESOURCES_DIR = 'resources' + +/** + * Bounds on what is copied. The allow-list already excludes the directories + * that make a checkout large, so these are a backstop. + */ +const MAX_FILES = 2000 +const MAX_BYTES = 20 * 1024 * 1024 + +/** One library folder under `resources/`, with the files it ships. */ +export interface LibraryResourceFolder { + name: string + /** Paths relative to the folder, `/`-separated and sorted. */ + files: string[] +} + +export interface AddLibraryResourceResult { + success: boolean + folder?: LibraryResourceFolder + error?: string +} + +/** + * Every library folder under the project's `resources/`, sorted by name. + * Returns `[]` when the directory is absent — a library project created + * before `resources/` was scaffolded is not an error. + * + * Loose files directly under `resources/` are not listed: they belong to no + * library and the build skips them. `README.md` is the one the editor itself + * writes there. + */ +export async function listLibraryResources(projectPath: string): Promise { + const root = join(projectPath, RESOURCES_DIR) + let entries + try { + entries = await readdir(root, { withFileTypes: true }) + } catch { + return [] + } + const folders: LibraryResourceFolder[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + folders.push({ name: entry.name, files: await walkFiles(join(root, entry.name)) }) + } + return folders.sort((a, b) => a.name.localeCompare(b.name)) +} + +/** + * Copy `sourcePath` into the project's `resources/` under its own name. + * + * Refuses rather than merges when a folder of that name is already there: + * copying over a library the author has edited in place would lose those + * edits silently. They remove it first. + */ +export async function addLibraryResource(projectPath: string, sourcePath: string): Promise { + // Arduino library names carry spaces ("Adafruit BusIO"), so the name is + // only checked for what would make it unusable as a path component. + const name = basename(sourcePath) + if (!isSafeRelativePath(name) || name.includes('/') || name.includes('\\')) { + return { success: false, error: `"${name}" is not a usable folder name.` } + } + + const destination = join(projectPath, RESOURCES_DIR, name) + + try { + await stat(destination) + return { success: false, error: `"${name}" is already in resources. Remove it first to replace it.` } + } catch { + // Absent, which is what we want. + } + + // Checked before the copy, so the wrong folder is reported here rather than + // landing an empty library that fails at build time. + const notALibrary = await whyNotALibrary(sourcePath) + if (notALibrary) return { success: false, error: notALibrary } + + const measured = await measure(sourcePath) + if ('error' in measured) return { success: false, error: measured.error } + + try { + await mkdir(join(projectPath, RESOURCES_DIR), { recursive: true }) + await cp(sourcePath, destination, { + recursive: true, + // A link out of the tree would put files the author never chose into a + // published archive. + dereference: false, + // `cp` asks about the root first and each directory before its contents, + // so refusing a directory prunes the whole subtree. + filter: (source) => { + const rel = relative(sourcePath, source).split(sep).join('/') + return rel === '' || isLibraryDir(rel) || isLibraryFile(rel) + }, + }) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + + return { success: true, folder: { name, files: await walkFiles(destination) } } +} + +/** Remove one library folder from `resources/`. */ +export async function removeLibraryResource( + projectPath: string, + folderName: string, +): Promise<{ success: boolean; error?: string }> { + const root = join(projectPath, RESOURCES_DIR) + // The name reaches here from the renderer, so it is checked as a path + // component before it is used as one. + if (!isSafeRelativePath(folderName) || folderName.includes('/') || folderName.includes('\\')) { + return { success: false, error: `"${folderName}" is not a folder in resources.` } + } + const target = join(root, folderName) + try { + assertPathContained(root, target, 'Folder name') + } catch { + return { success: false, error: `"${folderName}" is not a folder in resources.` } + } + + try { + await rm(target, { recursive: true, force: true }) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + +/** + * Why `root` is not an Arduino library, or `null` when it is one. + * + * Both are required: `library.properties` makes arduino-cli treat the folder as + * 1.5-format and recurse into `src/`, which is the only place either consumer + * reads sources from. + */ +async function whyNotALibrary(root: string): Promise { + const missing: string[] = [] + + try { + if (!(await stat(join(root, LIBRARY_PROPERTIES))).isFile()) missing.push(LIBRARY_PROPERTIES) + } catch { + missing.push(LIBRARY_PROPERTIES) + } + + try { + if (!(await stat(join(root, LIBRARY_SRC_DIR))).isDirectory()) missing.push(`${LIBRARY_SRC_DIR}/`) + } catch { + missing.push(`${LIBRARY_SRC_DIR}/`) + } + + if (missing.length === 0) return null + return `"${basename(root)}" has no ${missing.join(' and no ')} — ${LIBRARY_FOLDER_RULE}.` +} + +/** + * File count and total size of a candidate folder, or the reason it is too + * big to carry. Walked before the copy so an accidental pick fails fast + * instead of half-copying. + */ +async function measure(root: string): Promise<{ files: number; bytes: number } | { error: string }> { + let files = 0 + let bytes = 0 + const stack = [root] + while (stack.length > 0) { + const dir = stack.pop() as string + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const full = join(dir, entry.name) + const rel = relative(root, full).split(sep).join('/') + if (entry.isDirectory()) { + if (isLibraryDir(rel)) stack.push(full) + continue + } + if (!entry.isFile() || !isLibraryFile(rel)) continue + files += 1 + if (files > MAX_FILES) { + return { error: `That folder holds more than ${MAX_FILES} files — it does not look like a library.` } + } + bytes += (await stat(full)).size + if (bytes > MAX_BYTES) { + return { + error: `That folder is larger than ${MAX_BYTES / (1024 * 1024)} MB — it does not look like a library.`, + } + } + } + } + return { files, bytes } +} + +/** The library's files under `root`, relative and `/`-separated, sorted. + * Symlinks are not followed, and everything outside the library is left + * where it is — this is the same rule the build packages by. */ +async function walkFiles(root: string): Promise { + const found: string[] = [] + const stack = [root] + while (stack.length > 0) { + const dir = stack.pop() as string + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const full = join(dir, entry.name) + const rel = relative(root, full).split(sep).join('/') + if (entry.isDirectory()) { + if (isLibraryDir(rel)) stack.push(full) + } else if (entry.isFile() && isLibraryFile(rel)) { + found.push(rel) + } + } + } + return found.sort() +} diff --git a/src/backend/editor/services/project-service/utils/create-project.ts b/src/backend/editor/services/project-service/utils/create-project.ts index 904f8cee6..ae7728ec0 100644 --- a/src/backend/editor/services/project-service/utils/create-project.ts +++ b/src/backend/editor/services/project-service/utils/create-project.ts @@ -93,18 +93,25 @@ const createProjectDefaultStructure = ( } } - // 5. Library-only: emit the `library.json` manifest template. No - // POUs are created for libraries — the manifest is the single - // "open this by default" entry. + // 5. Library-only: emit the `library.json` manifest template and the + // `resources/` directory its blocks compile against. No POUs are + // created for libraries — the manifest is the single "open this by + // default" entry. `resources/` is not in `projectDefaultDirectories` + // because a program project has no use for it. if (isLibrary && built.libraryManifest !== undefined) { try { writeFileSync(`${basePath}/library.json`, built.libraryManifest, 'utf-8') + const resourcesPath = `${basePath}/resources` + if (!fileOrDirectoryExists(resourcesPath)) createDirectory(resourcesPath) + if (built.libraryResourcesReadme !== undefined) { + writeFileSync(`${resourcesPath}/README.md`, built.libraryResourcesReadme, 'utf-8') + } } catch (error) { return { success: false, error: { - title: 'Error creating library manifest', - description: `Failed to create library.json at ${basePath}`, + title: 'Error creating library files', + description: `Failed to create library.json or resources/ at ${basePath}`, error, }, } diff --git a/src/backend/editor/services/project-service/utils/read-project.ts b/src/backend/editor/services/project-service/utils/read-project.ts index 314a33fbc..ce3af5c0c 100644 --- a/src/backend/editor/services/project-service/utils/read-project.ts +++ b/src/backend/editor/services/project-service/utils/read-project.ts @@ -14,6 +14,7 @@ import { needsMigration, } from '@root/backend/shared/utils/migrate-project-to-name-type-system' import { getExtensionFromLanguage } from '@root/frontend/utils/PLC/pou-file-extensions' +import { findLastEndVarIndex } from '@root/frontend/utils/PLC/pou-text-parser' import { detectLanguageFromExtension, parseGraphicalPouFromString, @@ -163,25 +164,6 @@ function detectPouTypeFromPath(filePath: string): string { throw new Error(`Cannot determine POU type from path: ${filePath}`) } -/** - * Helper function to find the last END_VAR in the content - * @param content - The content to search - * @param startIndex - The index to start searching from - * @returns The index after the last END_VAR, or -1 if not found - */ -function findLastEndVarIndex(content: string, startIndex: number): number { - let lastEndVarIndex = -1 - const regex = /\bEND_VAR\b/gi - regex.lastIndex = startIndex - - let match: RegExpExecArray | null - while ((match = regex.exec(content)) !== null) { - lastEndVarIndex = match.index + match[0].length - } - - return lastEndVarIndex -} - /** * Fallback extraction when parsing fails - extracts raw variables block and body * @param content - The file content @@ -360,7 +342,7 @@ function readAndParsePouFile(filePath: string, fileName: string): PLCPou { const portPou = pou as unknown as { name: string pouType: string - interface?: { returnType?: string; variables: unknown[] } + interface?: { returnType?: string; extends?: string; variables: unknown[] } body: { language: string; value: unknown } documentation?: string } @@ -371,6 +353,11 @@ function readAndParsePouFile(filePath: string, fileName: string): PLCPou { name: portPou.name, variables: portPou.interface?.variables ?? [], ...(portPou.pouType === 'function' ? { returnType: portPou.interface?.returnType ?? '' } : {}), + // Only a function block may extend another. This flattening names each + // field explicitly, so anything unlisted is dropped. + ...(portPou.pouType === 'function-block' && portPou.interface?.extends + ? { extends: portPou.interface.extends } + : {}), body: portPou.body, documentation: portPou.documentation ?? '', }, diff --git a/src/backend/editor/utils/ipc-pou-to-flat.ts b/src/backend/editor/utils/ipc-pou-to-flat.ts index 2feecb894..c6bafc1b3 100644 --- a/src/backend/editor/utils/ipc-pou-to-flat.ts +++ b/src/backend/editor/utils/ipc-pou-to-flat.ts @@ -12,6 +12,8 @@ export function ipcPouToFlat(pou: IpcPou): FlatPou & { variablesText?: string } pouType: pou.type as FlatPou['pouType'], interface: { returnType: (data.returnType as string | undefined) ?? undefined, + // Named explicitly, or `serializePouToText` drops the EXTENDS clause. + ...(data.extends ? { extends: data.extends as string } : {}), variables: (data.variables ?? []) as NonNullable['variables'], }, body: pou.data.body as FlatPou['body'], diff --git a/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts index 8b7a24ff5..fb31e4d38 100644 --- a/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts +++ b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts @@ -13,11 +13,94 @@ import { buildCBlocksFromPous, composeFirmwareBundle } from '../steps/compose-fi const baseInput = { strucppFiles: {}, + libraryResources: [] as Array<{ name: string; files: Array<{ path: string; content: string; encoding?: 'base64' }> }>, cBlocks: { header: '// Empty file\n', code: null as string | null }, definesH: '#define PROGRAM_MD5 ""\n', firmwareSkeleton: {}, } +describe('composeFirmwareBundle — library resources', () => { + const demo_lib = { + name: 'DemoProtocol', + files: [ + { path: 'library.properties', content: 'name=DemoProtocol\narchitectures=esp32\n' }, + { path: 'src/DemoApi.h', content: '#pragma once\n' }, + { path: 'src/transport/DemoSerial.cpp', content: '// serial\n' }, + ], + } + + it('writes each library folder exactly as it stands', () => { + const out = composeFirmwareBundle({ ...baseInput, libraryResources: [demo_lib] }) + expect(out['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\narchitectures=esp32\n') + expect(out['libraries/DemoProtocol/src/DemoApi.h']).toBe('#pragma once\n') + expect(out['libraries/DemoProtocol/src/transport/DemoSerial.cpp']).toBe('// serial\n') + }) + + it('marks a precompiled binary so the write side decodes it', () => { + // Carried base64 because the archive it travelled in is JSON. Handing the + // bundle that text as a file's contents would produce an archive the + // linker rejects, with nothing in the message pointing back here. + const out = composeFirmwareBundle({ + ...baseInput, + libraryResources: [ + { + name: 'DemoProtocol', + files: [ + { path: 'library.properties', content: 'name=DemoProtocol\nprecompiled=true\n' }, + { path: 'src/esp32/libdemo.a', content: 'AAECAw==', encoding: 'base64' }, + ], + }, + ], + }) + + expect(out['libraries/DemoProtocol/src/esp32/libdemo.a']).toEqual({ base64: 'AAECAw==' }) + // Text beside it is still a plain string. + expect(out['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\nprecompiled=true\n') + }) + + it("leaves a library's own library.properties alone", () => { + // It may carry `depends`, `precompiled` or a narrower `architectures`. + const out = composeFirmwareBundle({ ...baseInput, libraryResources: [demo_lib] }) + expect(out['libraries/DemoProtocol/library.properties']).toContain('architectures=esp32') + }) + + it('supplies one only when the folder has none', () => { + // Without it arduino-cli reads the folder as 1.0 legacy and ignores + // everything below its root — verified against arduino-cli. + const out = composeFirmwareBundle({ + ...baseInput, + libraryResources: [{ name: 'BareLib', files: [{ path: 'src/A.h', content: '' }] }], + }) + const props = out['libraries/BareLib/library.properties'] + expect(props).toContain('name=BareLib') + expect(props).toContain('architectures=*') + }) + + it('keeps several libraries apart', () => { + const out = composeFirmwareBundle({ + ...baseInput, + libraryResources: [ + { name: 'DemoProtocol', files: [{ path: 'src/utils.h', content: '// protocol\n' }] }, + { name: 'DemoSupport', files: [{ path: 'src/utils.h', content: '// openplc\n' }] }, + ], + }) + expect(out['libraries/DemoProtocol/src/utils.h']).toBe('// protocol\n') + expect(out['libraries/DemoSupport/src/utils.h']).toBe('// openplc\n') + }) + + it('lands beside the sketch, never over a skeleton or generated file', () => { + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: { 'src/arduino.cpp': '// real HAL\n' }, + libraryResources: [{ name: 'DemoProtocol', files: [{ path: 'src/arduino.cpp', content: '// hijacked\n' }] }], + cBlocks: { header: '// real c_blocks\n', code: null }, + definesH: '// real defines\n', + }) + expect(out['src/arduino.cpp']).toBe('// real HAL\n') + expect(out['src/c_blocks.h']).toBe('// real c_blocks\n') + }) +}) + describe('composeFirmwareBundle — skeleton passthrough', () => { it('passes every skeleton entry through verbatim when no other inputs are present', () => { const skeleton = { @@ -126,6 +209,7 @@ describe('composeFirmwareBundle — c_blocks_code.cpp overwrite semantics', () = describe('composeFirmwareBundle — full layout snapshot', () => { it('produces the canonical simulator file map for a project with C/C++ POUs', () => { const out = composeFirmwareBundle({ + libraryResources: [], firmwareSkeleton: { 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', 'examples/Baremetal/c_blocks_code.cpp': 'STATIC_BASELINE', @@ -160,6 +244,7 @@ describe('composeFirmwareBundle — full layout snapshot', () => { it('produces the canonical simulator file map for a project with NO C/C++ POUs', () => { const out = composeFirmwareBundle({ + libraryResources: [], firmwareSkeleton: { 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', 'examples/Baremetal/c_blocks_code.cpp': 'STATIC_BASELINE_KEPT', @@ -203,6 +288,7 @@ describe('composeFirmwareBundle — OpenPLCUserLib.h stub', () => { // with `fatal error: OpenPLCUserLib.h: No such file or directory`. it('always emits the stub under src/OpenPLCUserLib.h', () => { const out = composeFirmwareBundle({ + libraryResources: [], firmwareSkeleton: {}, strucppFiles: {}, cBlocks: { header: '', code: null }, diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index e49766067..64965b9cc 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -13,6 +13,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' +import { buildArduinoCliCompileArgs } from '../../firmware/build-arduino-cli-args' import type { PLCProjectData } from '../../types/PLC/open-plc' import type { CompilerPlatformPort, @@ -204,6 +205,114 @@ describe('runCompilePipeline — simulator path', () => { // was resolved onto boardEntry but never forwarded to installArduinoCore, // so vendor cores outside arduino-cli's built-in index could not be // installed — "Platform 'industrialshields:esp32' not found". + it('materialises resources from enabled libraries into the firmware bundle', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + libraries: [{ name: 'demo_lib', version: '0.1.0' }], + } as unknown as PLCProjectData, + libraryArchives: [ + { + manifest: { name: 'demo_lib' }, + // Two library folders in one archive's resources/ tree. + resources: [ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + { path: 'DemoProtocol/src/transport/Serial.cpp', content: '// serial\n' }, + { path: 'DemoSupport/src/DemoSupport.h', content: '// support\n' }, + ], + }, + // Resolved alongside the enabled set but not enabled by the + // project — its resources must not reach the build. + { + manifest: { name: 'oscat-basic' }, + resources: [{ path: 'Oscat/src/Oscat.h', content: '// oscat\n' }], + }, + ], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + // Each folder in resources/ becomes its own library, layout preserved. + expect(callArgs.files['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\n') + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('#pragma once\n') + expect(callArgs.files['libraries/DemoProtocol/src/transport/Serial.cpp']).toBe('// serial\n') + expect(callArgs.files['libraries/DemoSupport/src/DemoSupport.h']).toBe('// support\n') + + // Not enabled by the project, so neither its files nor its --library + // reach the build. + expect(callArgs.files['libraries/Oscat/src/Oscat.h']).toBeUndefined() + + // arduino-cli only recurses into a library that has a library.properties, + // so each folder has to be named on the command line as its own library. + const [, argOptions] = jest.mocked(buildArduinoCliCompileArgs).mock.calls.at(-1)! + expect(argOptions.resourceLibraryPaths).toEqual(['libraries/DemoProtocol', 'libraries/DemoSupport']) + }) + + it("materialises a library project's own resources when it verifies itself", async () => { + // A library project does not list itself in `libraries`, so without this + // its blocks resolve their includes against whatever is installed on the + // machine — or fail. + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + ownLibraryResources: [ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + ], + } as unknown as PLCProjectData, + libraryArchives: [], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('#pragma once\n') + + const [, argOptions] = jest.mocked(buildArduinoCliCompileArgs).mock.calls.at(-1)! + expect(argOptions.resourceLibraryPaths).toEqual(['libraries/DemoProtocol']) + }) + + it('skips a resource whose path would escape the build directory', async () => { + // Archive paths become files under the compilation path, and an installed + // `.stlib` is untrusted by the time a consuming project unpacks it. + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + libraries: [{ name: 'demo_lib', version: '0.1.0' }], + } as unknown as PLCProjectData, + libraryArchives: [ + { + manifest: { name: 'demo_lib' }, + resources: [ + { path: '../../escaped.h', content: '// escaped\n' }, + { path: '/etc/passwd', content: '// absolute\n' }, + { path: 'DemoProtocol/../../escaped.h', content: '// traversal\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '// fine\n' }, + ], + }, + ], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + const keys = Object.keys(callArgs.files) + expect(keys.some((k) => k.includes('..'))).toBe(false) + expect(keys.some((k) => k.includes('passwd'))).toBe(false) + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('// fine\n') + }) + it('forwards boardEntry.boardManagerUrl to installArduinoCore', async () => { const port = makePort() const { emit } = captureEvents() diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 01ac1a53f..efbaec724 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -37,6 +37,7 @@ import { describeVppRuntimeMismatch, isStrucppCompatibleRuntime, } from '../firmware/runtime-version-gate' +import { projectAndLibraryTypeNames } from '../library/inject-library-blocks' import { buildKnownPous, emitCompileErrorEvents } from '../library/program-build-helpers' import { runProgramBuildPipeline } from '../library/program-build-pipeline' import type { DevicePin } from '../types/PLC/devices' @@ -46,6 +47,7 @@ import type { DevicePin } from '../types/PLC/devices' // (plural `configurations`) and converts at the pipeline entry — see C1 // in the architectural plan. import type { PLCProjectData } from '../types/PLC/open-plc' +import { isSafeRelativePath } from '../utils/path-safety' import { buildCBlocksFromPous, composeFirmwareBundle } from './steps/compose-firmware-bundle' import { generateRuntimeConfs } from './steps/generate-confs' import { generateDefinesContent } from './steps/generate-defines' @@ -332,6 +334,63 @@ function bailError( * shape — `success`, `errors`, `binary`, `md5`, `uploaded` — that * adapters surface to their `CompilerPort` callers. */ +/** + * The libraries the enabled `.stlib` archives carry in `resources/`, grouped by + * folder. Each folder is an ordinary library — `library.properties` beside a + * `src/` directory — and is materialised as one. + * + * Filtered by the project's enabled libraries: the resolved archive set also + * holds the bundled ones. A file not inside a folder is skipped. On a + * same-named folder from two archives the later wins, and archive order is + * fixed. + */ +function collectLibraryResources( + projectData: PLCProjectData, + libraryArchives: unknown[], +): Array<{ name: string; files: Array<{ path: string; content: string }> }> { + const enabled = new Set((projectData.libraries ?? []).map((ref) => ref.name)) + + const byLibrary = new Map>() + const addResource = (resource: { path: string; content: string }): void => { + // These paths come off an installed archive and become files under the + // build directory, so they are checked before they are used as one. + if (!isSafeRelativePath(resource.path)) return + // The first segment names the library folder the file belongs to. + const separator = resource.path.indexOf('/') + if (separator <= 0) return + const name = resource.path.slice(0, separator) + let files = byLibrary.get(name) + if (!files) { + files = new Map() + byLibrary.set(name, files) + } + files.set(resource.path.slice(separator + 1), resource.content) + } + + for (const archive of (enabled.size === 0 ? [] : libraryArchives) as Array<{ + manifest?: { name?: string } + resources?: Array<{ path: string; content: string }> + }>) { + const archiveName = archive?.manifest?.name + if (typeof archiveName !== 'string' || !enabled.has(archiveName)) continue + for (const resource of archive.resources ?? []) { + addResource(resource) + } + } + + // A library project does not list itself, so its own resources arrive here + // directly when it verifies. + const own = (projectData as { ownLibraryResources?: Array<{ path: string; content: string }> }).ownLibraryResources + for (const resource of own ?? []) { + addResource(resource) + } + + return [...byLibrary].map(([name, files]) => ({ + name, + files: [...files].map(([path, content]) => ({ path, content })), + })) +} + export async function runCompilePipeline( args: RunCompilePipelineArgs, port: CompilerPlatformPort, @@ -406,6 +465,7 @@ async function runCompilePipelineInner( originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> } const originalCppPous = processedData.originalCppPous ?? [] + const libraryResources = collectLibraryResources(projectData, libraryArchives) // --------------------------------------------------------------------- // Step 0b: Reject blank FBD variable blocks before XML generation. @@ -541,12 +601,15 @@ async function runCompilePipelineInner( } emit({ stage: 'runtime-v4-bundle', message: 'Composing Runtime v4 upload bundle...', level: 'info' }) - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + // The enabled libraries' data types count as well as the project's: a pin + // typed by one has to be spelled the way strucpp declared it. + const userTypeNames = projectAndLibraryTypeNames(projectData, libraryArchives) const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) const bundle = composeRuntimeV4Bundle({ programSt, md5, strucppFiles: strucppFilesMap, + libraryResources, cBlocks: { header: cBlocks.header, code: cBlocks.code }, strucppRuntimeHeaders, confs: { @@ -821,6 +884,7 @@ async function runCompilePipelineInner( const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) const firmwareFiles = composeFirmwareBundle({ strucppFiles: strucppFilesMap, + libraryResources, cBlocks, definesH, vppConfigH, @@ -833,6 +897,8 @@ async function runCompilePipelineInner( const arduinoArgs = buildArduinoCliCompileArgs(boardEntry, { sketchPath: 'examples/Baremetal/Baremetal.ino', libraryPath: 'src', + // Relative to the compilation root, matching `libraryPath` / `sketchPath`. + resourceLibraryPaths: libraryResources.map((library) => `libraries/${library.name}`), avrLibStdCppInclude, parallel: arduinoCliParallel, // Prebuilt arduino-hal: link the precompiled vendor library alongside the diff --git a/src/backend/shared/compile/steps/compose-firmware-bundle.ts b/src/backend/shared/compile/steps/compose-firmware-bundle.ts index 83fad6ce1..7b5511c41 100644 --- a/src/backend/shared/compile/steps/compose-firmware-bundle.ts +++ b/src/backend/shared/compile/steps/compose-firmware-bundle.ts @@ -30,6 +30,7 @@ * from one to the other without re-deriving inputs. */ +import type { BundleFile } from '../../../../middleware/shared/utils/library/bundle-file' import type { CppPouData as CppPouDataCode } from '../../utils/cpp/generateCBlocksCode' import { generateCBlocksCode } from '../../utils/cpp/generateCBlocksCode' import type { CppPouData as CppPouDataHeader } from '../../utils/cpp/generateCBlocksHeader' @@ -51,6 +52,10 @@ export interface ComposeFirmwareBundleInput { * `examples/Baremetal/c_blocks_code.cpp` alone. Otherwise * pass `generateCBlocksCode(originalCppPous)` and the * static file gets overwritten with the user-facing version. */ + /** Libraries the enabled `.stlib` archives carry, each an ordinary + * library folder. `path` is relative to that folder's root and is + * written as-is. Empty when no enabled library ships resources. */ + libraryResources: Array<{ name: string; files: Array<{ path: string; content: string; encoding?: 'base64' }> }> cBlocks: { header: string code: string | null @@ -118,6 +123,7 @@ export function buildCBlocksFromPous( * Assemble the firmware file tree. * * Layout produced (paths relative to project root): + * - `libraries//…` — one Arduino library per resource library * - `examples/Baremetal/Baremetal.ino` — from skeleton * - `src/c_blocks_code.cpp` — written when `cBlocks.code !== null` * - `examples/Baremetal/modules/...` — from skeleton (Arduino library helpers) @@ -128,20 +134,57 @@ export function buildCBlocksFromPous( * - `src/.hpp` — from skeleton (strucpp runtime headers) * - other skeleton entries — passed through verbatim * - * Ordering: skeleton first, then overwrites. Strucpp output - * overwrites any same-named skeleton file (strucpp generally adds - * new files; collisions are intentional when they happen). + * Ordering: resource libraries first, then the skeleton, then + * overwrites, so a collision resolves in the build's favour. + * Strucpp output overwrites any same-named skeleton file + * (strucpp generally adds new files; collisions are intentional when + * they happen). * `c_blocks.h` and `defines.h` overwrite the skeleton's static * stubs. `c_blocks_code.cpp` is overwritten ONLY when the project * has C/C++ POUs — otherwise the static baseline stays. */ -export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { - const { strucppFiles, cBlocks, definesH, vppConfigH, firmwareSkeleton } = input +/** + * A stand-in `library.properties` for a folder that ships none. Without one + * arduino-cli reads the folder as a 1.0 legacy library and ignores everything + * below its root; with one it compiles `src/` recursively. `architectures=*` + * so the target never filters it out. + */ +function libraryProperties(name: string): string { + return [ + `name=${name}`, + 'version=1.0.0', + 'author=OpenPLC Editor', + 'maintainer=OpenPLC Editor ', + 'sentence=Resources shipped by an OpenPLC library', + 'paragraph=Materialised from the library archive so its C/C++ blocks compile against the sources they were built with.', + 'category=Other', + 'architectures=*', + '', + ].join('\n') +} + +export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { + const { strucppFiles, cBlocks, definesH, vppConfigH, firmwareSkeleton, libraryResources } = input + + const files: Record = {} + + // Each folder is written as it stands and named with its own `--library`, + // which is what makes arduino-cli compile everything under its `src/`. + // They sit beside the sketch, so a resource cannot shadow a firmware file. + for (const library of libraryResources) { + const root = `libraries/${library.name}` + for (const file of library.files) { + files[`${root}/${file.path}`] = file.encoding === 'base64' ? { base64: file.content } : file.content + } + if (!library.files.some((file) => file.path === 'library.properties')) { + files[`${root}/library.properties`] = libraryProperties(library.name) + } + } - // Skeleton first (every Baremetal.ino, arduino HAL, strucpp + // Skeleton next (every Baremetal.ino, arduino HAL, strucpp // runtime header, etc.). Subsequent overwrites replace specific // entries. - const files: Record = { ...firmwareSkeleton } + Object.assign(files, firmwareSkeleton) // Strucpp output lands under `src/` alongside the runtime glue // — arduino-cli's `--library src` pass picks every TU there into diff --git a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts index 5613b5c89..740a1a713 100644 --- a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts +++ b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts @@ -156,6 +156,41 @@ describe('buildArduinoCliCompileArgs', () => { expect(args.indexOf('/packages/p1am/hal/arduino/lib')).toBeLessThan(args.indexOf('--export-binaries')) }) + it('emits one --library per resource library, after the main src/', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { + sketchPath: 'examples/Baremetal/Baremetal.ino', + libraryPath: 'src', + resourceLibraryPaths: ['libraries/demo_lib', 'libraries/other_lib'], + parallel: false, + }, + ) + expect(args.filter((a) => a === '--library')).toHaveLength(3) + const firstLib = args.indexOf('--library') + expect(args.slice(firstLib, firstLib + 6)).toEqual([ + '--library', + 'src', + '--library', + 'libraries/demo_lib', + '--library', + 'libraries/other_lib', + ]) + expect(args.indexOf('libraries/other_lib')).toBeLessThan(args.indexOf('--export-binaries')) + }) + + it('emits the same argv as before when no library ships resources', () => { + // A project with no resource libraries must produce a byte-identical + // command line, so adding the option cannot disturb an existing build. + const base = { sketchPath: 'a.ino', libraryPath: 'src', parallel: false } + const withoutOption = buildArduinoCliCompileArgs({ platform: 'arduino:avr:mega' }, base) + const withEmpty = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { ...base, resourceLibraryPaths: [] }, + ) + expect(withEmpty).toEqual(withoutOption) + }) + it('emits a single --library when prebuiltLibraryPath is absent', () => { const args = buildArduinoCliCompileArgs( { platform: 'arduino:avr:mega' }, diff --git a/src/backend/shared/firmware/build-arduino-cli-args.ts b/src/backend/shared/firmware/build-arduino-cli-args.ts index f63098f8b..a98933e44 100644 --- a/src/backend/shared/firmware/build-arduino-cli-args.ts +++ b/src/backend/shared/firmware/build-arduino-cli-args.ts @@ -38,6 +38,12 @@ export interface BuildArduinoCliCompileArgsOptions { * main libraryPath as usual. */ prebuiltLibraryPath?: string + /** + * Further `--library` directories, one per library materialised from an + * enabled `.stlib`. Each carries a `library.properties`, so arduino-cli + * reads it as 1.5-format and compiles the sources under its `src/`. + */ + resourceLibraryPaths?: readonly string[] /** * Filesystem path to the avr-libstdcpp include directory. Appended * as `-I` onto `compiler.cpp.extra_flags` when the board's @@ -105,6 +111,9 @@ export function buildArduinoCliCompileArgs( if (options.prebuiltLibraryPath) { args.push('--library', options.prebuiltLibraryPath) } + for (const resourceLibrary of options.resourceLibraryPaths ?? []) { + args.push('--library', resourceLibrary) + } args.push('--export-binaries', '-b', entry.platform, options.sketchPath) if (options.trailingArgs && options.trailingArgs.length > 0) { diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index 3390c4d6a..b2170ac53 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -196,6 +196,70 @@ describe('parseLibraryManifest', () => { }) }) +// --------------------------------------------------------------------------- +// parseLibraryManifest — the `build` block (Build Settings) +// --------------------------------------------------------------------------- + +describe('parseLibraryManifest — build block', () => { + const withBuild = (build: unknown) => + parseLibraryManifest(JSON.stringify({ name: 'x', version: '1.0', namespace: 'x', build })) + + it('defaults to an Arduino target with no core when the block is absent', () => { + const res = parseLibraryManifest(VALID_MANIFEST_JSON) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'arduino' }) + }) + + it('reads the mode and the core', () => { + const res = withBuild({ verify: 'arduino', core: 'esp32:esp32' }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'arduino', core: 'esp32:esp32' }) + }) + + it('keeps a core recorded alongside a non-Arduino mode', () => { + // The dialog remembers the core while another mode is selected, so + // switching back does not lose the choice. + const res = withBuild({ verify: 'runtime', core: 'esp32:esp32' }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'runtime', core: 'esp32:esp32' }) + }) + + it('accepts every mode the dialog offers', () => { + for (const mode of ['arduino', 'runtime', 'off']) { + const res = withBuild({ verify: mode }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget.mode).toBe(mode) + } + }) + + it('rejects an unknown mode rather than falling back to the default', () => { + // A typo that silently verified against a different toolchain would + // report on something the author did not ask about. + const res = withBuild({ verify: 'arduno' }) + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build\.verify must be one of/) + }) + + it('rejects a non-object build block', () => { + const res = withBuild('arduino') + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build must be a JSON object/) + }) + + it('rejects an empty core', () => { + const res = withBuild({ core: '' }) + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build\.core must be a non-empty string/) + }) +}) + // --------------------------------------------------------------------------- // stubProgramFor // --------------------------------------------------------------------------- @@ -355,6 +419,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' } as const, extra: {} as Record, } @@ -528,6 +593,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' }, extra: { description: 'a demo lib', displayName: 'Demo Library' }, }, { @@ -638,6 +704,78 @@ describe('libraryBuildFromTranspiledSt', () => { expect(passed.map((p) => p.fileName)).toEqual(['CppBlk.cpp']) }) + it('stamps resources onto the archive, and omits the field when there are none', () => { + const withRes: { manifest: { name: string }; dependencies: unknown[]; resources?: unknown[] } = { + manifest: { name: 'demo_lib' }, + dependencies: [], + } + __setStrucppRuntimeForTests( + makeStrucppStub({ + compileStlib: jest + .fn() + .mockReturnValue({ success: true, archive: withRes }) as unknown as StrucppRuntime['compileStlib'], + }), + ) + + const programSt = + 'FUNCTION_BLOCK Tank\n VAR sp : INT; END_VAR\n sp := 1;\nEND_FUNCTION_BLOCK\n' + + 'PROGRAM main\n VAR LocalVar : INT; END_VAR\n LocalVar := 3;\nEND_PROGRAM\n' + const pous = [ + { name: 'Tank', kind: 'FUNCTION_BLOCK' as const }, + { name: STUB.STUB_PROGRAM_NAME, kind: 'PROGRAM' as const }, + ] + + libraryBuildFromTranspiledSt(programSt, pous, manifest, { + resources: [ + { path: 'DemoApi.h', content: '#pragma once\n' }, + { path: 'transport/Serial.h', content: '// serial\n' }, + ], + }) + expect(withRes.resources).toEqual([ + { path: 'DemoApi.h', content: '#pragma once\n' }, + { path: 'transport/Serial.h', content: '// serial\n' }, + ]) + + // Absent, not empty — an archive written here still loads in an editor + // that knows nothing about resources. + const noRes: { manifest: { name: string }; dependencies: unknown[]; resources?: unknown[] } = { + manifest: { name: 'demo_lib' }, + dependencies: [], + } + __setStrucppRuntimeForTests( + makeStrucppStub({ + compileStlib: jest + .fn() + .mockReturnValue({ success: true, archive: noRes }) as unknown as StrucppRuntime['compileStlib'], + }), + ) + libraryBuildFromTranspiledSt(programSt, pous, manifest) + expect('resources' in noRes).toBe(false) + }) + + it('accepts a hyphenated name from a library that ships native blocks', () => { + // The editor used to graft each block as `__`, so the name had + // to be a C identifier. Upstream now grafts a block under its own name, and + // nothing builds a symbol from `manifest.name` any more — `checkPathId` at + // parse time is the whole rule. + const compileStlib = jest.fn().mockReturnValue({ success: true, archive: { manifest: {}, dependencies: [] } }) + __setStrucppRuntimeForTests( + makeStrucppStub({ compileStlib: compileStlib as unknown as StrucppRuntime['compileStlib'] }), + ) + + const res = libraryBuildFromTranspiledSt( + 'PROGRAM main\n VAR LocalVar : INT; END_VAR\n LocalVar := 3;\nEND_PROGRAM\n', + [{ name: STUB.STUB_PROGRAM_NAME, kind: 'PROGRAM' }], + { ...manifest, name: 'demo-lib', namespace: 'demo_lib' }, + { + nativeSources: [{ fileName: 'CppOnly.cpp', source: 'FUNCTION_BLOCK CppOnly\nEND_FUNCTION_BLOCK\n' }], + }, + ) + + expect(res.success).toBe(true) + expect(compileStlib).toHaveBeenCalled() + }) + it('still refuses a library with neither ST nor native content', () => { const compileStlib = jest.fn() __setStrucppRuntimeForTests( diff --git a/src/backend/shared/library/__tests__/inject-library-blocks.test.ts b/src/backend/shared/library/__tests__/inject-library-blocks.test.ts index f2f9e3a41..71b3b0170 100644 --- a/src/backend/shared/library/__tests__/inject-library-blocks.test.ts +++ b/src/backend/shared/library/__tests__/inject-library-blocks.test.ts @@ -1,6 +1,11 @@ import type { StlibArchiveDTO } from '../../../../middleware/shared/ports/library-port' import type { PLCProjectData } from '../../../../middleware/shared/ports/types' -import { findLibrariesMissingNativeSources, injectLibraryBlocks, libraryBlockPouName } from '../inject-library-blocks' +import { + findLibrariesMissingNativeSources, + injectLibraryBlocks, + libraryBlockPouName, + projectAndLibraryTypeNames, +} from '../inject-library-blocks' // -- helpers ------------------------------------------------------------------ @@ -47,7 +52,7 @@ function project(overrides: { libraries?: Array<{ name: string; version: string function archive( name: string, blocks: Array<{ name: string; language: 'cpp' | 'python'; file?: string; source?: string | null }> = [], - opts: { stBlocks?: string[] } = {}, + opts: { stBlocks?: string[]; namespace?: string; types?: Array<{ name: string; kind: string }> } = {}, ): StlibArchiveDTO { const sources: Array<{ fileName: string; source: string }> = [] const functionBlocks: unknown[] = (opts.stBlocks ?? []).map((n) => ({ @@ -72,7 +77,16 @@ function archive( if (source !== null) sources.push({ fileName, source }) } - return { manifest: { name, version: '1.0.0', functionBlocks }, sources } as unknown as StlibArchiveDTO + return { + manifest: { + name, + version: '1.0.0', + functionBlocks, + ...(opts.namespace ? { namespace: opts.namespace } : {}), + ...(opts.types ? { types: opts.types } : {}), + }, + sources, + } as unknown as StlibArchiveDTO } // -- tests -------------------------------------------------------------------- @@ -83,6 +97,90 @@ describe('libraryBlockPouName', () => { }) }) +describe('the identifier a grafted block is prefixed with', () => { + // The prefix becomes an ST POU name. `manifest.name` is only checked for path + // safety, so `modbee-protocol` is a legal name — and produced + // `modbee-protocol__TOPIC`, which no parser accepts. The failure appeared + // only in the CONSUMING project, naming a POU nobody wrote. + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + + it('takes the namespace, so a hyphenated library name still parses', () => { + const grafted = injectLibraryBlocks(data, [ + archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }], { namespace: 'modbee_protocol' }), + ]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) + + it('falls back to folding the name when an older archive declares no namespace', () => { + const grafted = injectLibraryBlocks(data, [archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }])]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) + + it('does not let a folded name start with a digit', () => { + const numeric = project({ pous: ['main'], libraries: [{ name: '3d-tools', version: '1.0.0' }] }) + const grafted = injectLibraryBlocks(numeric, [archive('3d-tools', [{ name: 'MOVE', language: 'cpp' }])]) + expect(grafted.pous.map((pou) => pou.name)).toContain('_3d_tools__MOVE') + }) + + it('ignores a namespace that is not an identifier and folds instead', () => { + const grafted = injectLibraryBlocks(data, [ + archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }], { namespace: 'not an identifier' }), + ]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) +}) + +describe('projectAndLibraryTypeNames', () => { + // The native bridge spells a pin `strucpp::IEC_` for a declared data + // type and `strucpp::` otherwise. Built from the project alone, a pin + // typed by a LIBRARY's enum was spelled bare while strucpp had declared + // `IEC_`, and the POU glue failed on the pointer assignment. + const enumType = { name: 'MB_SPACE', kind: 'enum' } + + it('includes the types an enabled library declares', () => { + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).toContain('MB_SPACE') + }) + + it('leaves out a library the project has not enabled', () => { + const data = project({ pous: ['main'] }) + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).not.toContain('MB_SPACE') + }) + + it('keeps the project own types alongside them', () => { + const data = { + ...project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }), + dataTypes: [{ name: 'MOTOR' }], + } as unknown as PLCProjectData + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).toEqual(expect.arrayContaining(['MOTOR', 'MB_SPACE'])) + }) + + it('includes every kind of data type, not just enumerations', () => { + // The bridge's IEC_ prefix rule applies to all three: strucpp aliases a + // structure and an array to themselves and an enumeration to IEC_ENUM<>, + // so a pin of any of them is spelled IEC_. + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + const names = projectAndLibraryTypeNames(data, [ + archive('modbee-protocol', [], { + types: [ + { name: 'MB_SPACE', kind: 'enum' }, + { name: 'MB_CFG', kind: 'struct' }, + { name: 'MB_TREND', kind: 'alias' }, + ], + }), + ]) + expect(names).toEqual(expect.arrayContaining(['MB_SPACE', 'MB_CFG', 'MB_TREND'])) + }) + + it('copes with an archive that declares no types at all', () => { + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + expect(projectAndLibraryTypeNames(data, [archive('modbee-protocol')])).toEqual([]) + }) +}) + describe('injectLibraryBlocks', () => { it('returns the same object when the project enables no libraries', () => { const data = project({ pous: ['main'] }) diff --git a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts index b2fcd4df8..ed9296acc 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -79,6 +79,15 @@ function makePort(): PortHarness { throwOn: {}, } harness.port = { + listProjectFiles(_projectPath: string, relPath: string) { + const prefix = `${relPath}/` + return Promise.resolve( + [...harness.files.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) + .sort(), + ) + }, async computeMd5(input: string) { // Deterministic stand-in — same input → same hash, different // inputs → different hashes. Length-prefix makes near-duplicates @@ -99,6 +108,23 @@ function makePort(): PortHarness { if (relPath === 'library.json') return harness.manifestContent return harness.files.get(relPath) ?? null }, + async readBuildFileBase64(_projectPath: string, relPath: string) { + if (harness.throwOn.readBuildFileBase64) throw harness.throwOn.readBuildFileBase64 + const content = harness.files.get(relPath) + if (content === undefined) return null + return Buffer.from(content, 'utf-8').toString('base64') + }, + listProjectDirs(_projectPath: string, relPath: string) { + const prefix = `${relPath}/` + const names = new Set() + for (const key of harness.files.keys()) { + if (!key.startsWith(prefix)) continue + const rest = key.slice(prefix.length) + const slash = rest.indexOf('/') + if (slash > 0) names.add(rest.slice(0, slash)) + } + return Promise.resolve([...names].sort()) + }, async writeBuildFile(_projectPath: string, relPath: string, content: string) { if (harness.throwOn.writeBuildFile) throw harness.throwOn.writeBuildFile harness.files.set(relPath, content) @@ -122,6 +148,22 @@ function makePort(): PortHarness { return harness } +/** + * The harness MD5 of what the orchestrator actually hashes: `program.st`, + * each C/C++ block's name and body, each resource, and the verify target. + */ +function verifyInputsMd5( + nativeSources: Array<{ fileName: string; source: string }> = [], + resources: Array<{ path: string; content: string }> = [], + target: { mode: string; core?: string } = { mode: 'arduino' }, +): string { + const cppSource = nativeSources.map((n) => `${n.fileName}\n${n.source}`).join('\n') + const resourceSource = resources.map((r) => `${r.path}\n${r.content}`).join('\n') + const targetSource = `${target.mode}\n${target.core ?? ''}` + const input = `${FAKE_PROGRAM_ST}\n${cppSource}\n${resourceSource}\n${targetSource}` + return `md5-${input.length}-${input.charCodeAt(0)}` +} + function projectDataEmpty(): PLCProjectData { return { pous: [], @@ -144,7 +186,7 @@ beforeEach(() => { mockPrepareXml.mockReturnValue({ projectData: projectDataEmpty(), knownPous: [], - manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', extra: {} }, + manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', verifyTarget: { mode: 'arduino' }, extra: {} }, }) mockLibraryBuild.mockReturnValue({ success: true, archive: { stub: true }, errors: [] }) }) @@ -185,7 +227,7 @@ describe('runLibraryBuildPipeline', () => { 'Starting library build...', 'Manifest OK — building "lib" v0.1.0.', 'Transpiling project to Structured Text', - 'Verifying with OpenPLC Simulator (avr-gcc)...', + 'Verifying library compile...', 'Compiling library archive...', 'Library built successfully: build/lib.stlib', ]), @@ -299,7 +341,7 @@ describe('runLibraryBuildPipeline', () => { // Pre-seed the cache. computeMd5 in the harness is deterministic // off program.st length + first char; the orchestrator's value // will match this when the same transpiler output replays. - const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` + const expectedMd5 = verifyInputsMd5() harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { events, emit } = captureEvents() @@ -318,9 +360,211 @@ describe('runLibraryBuildPipeline', () => { expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(true) }) + it('reads the resources/ tree and hands it to the build', async () => { + const harness = makePort() + harness.files.set('resources/DemoProtocol/library.properties', 'name=DemoProtocol\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '#pragma once\n') + harness.files.set('resources/DemoProtocol/src/transport/DemoSerial.cpp', '// serial\n') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + // Paths keep the author's layout, relative to `resources/`, so the first + // segment names the library folder the file belongs to. + expect(aux.resources).toEqual([ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + { path: 'DemoProtocol/src/transport/DemoSerial.cpp', content: '// serial\n' }, + ]) + }) + + it('takes only the library out of a resource folder', async () => { + // A folder is the author's checkout, so it arrives holding a build tree, a + // git directory and loose files. Only `library.properties` and `src/` are + // read: everything else is what the two consumers never look at, and + // reading it was thousands of files discarded one warning at a time. + const harness = makePort() + harness.files.set('resources/README.md', '# written by the editor\n') + harness.files.set('resources/DemoProtocol/library.properties', 'name=DemoProtocol\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '// api\n') + harness.files.set('resources/DemoProtocol/build/DemoApi.o', 'object file\n') + harness.files.set('resources/DemoProtocol/test/test_api.cpp', '// test\n') + harness.files.set('resources/DemoProtocol/CMakeLists.txt', 'project(demo)\n') + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + expect(aux.resources).toEqual([ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '// api\n' }, + ]) + // Silently, not one warning per file skipped. + expect(events.filter((e) => e.level === 'warning')).toEqual([]) + }) + + it('fails the build when a resource folder is not a library', async () => { + // Shipping the folder anyway produces an archive whose consumer finds no + // headers, and the error surfaces there instead of here. + const harness = makePort() + harness.files.set('resources/DemoProtocol/DemoApi.h', '// header at the root\n') + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(events.some((e) => e.level === 'error' && e.message.includes('DemoProtocol'))).toBe(true) + expect(events.some((e) => e.message.includes('library.properties'))).toBe(true) + }) + + it('carries a precompiled binary out of src/ base64-encoded', async () => { + // A library that declares `precompiled=true` ships a `.a` beside its + // headers. Dropping it leaves the consumer to link against nothing. + const harness = makePort() + harness.files.set('resources/DemoProtocol/library.properties', 'name=DemoProtocol\nprecompiled=true\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '// api\n') + harness.files.set('resources/DemoProtocol/src/esp32/libdemo.a', 'binary\uFFFDbytes') + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + const binary = aux.resources.find((r: { path: string }) => r.path.endsWith('libdemo.a')) + expect(binary.encoding).toBe('base64') + expect(Buffer.from(binary.content, 'base64').toString('utf-8')).toBe('binary\uFFFDbytes') + // The text beside it is untouched. + const header = aux.resources.find((r: { path: string }) => r.path.endsWith('DemoApi.h')) + expect(header.encoding).toBeUndefined() + expect(events.some((e) => e.message.includes('1 binary file(s)'))).toBe(true) + }) + + it('re-verifies when a resource changed but nothing else did', async () => { + // The blocks are compiled against these, so a changed resource has to + // invalidate a cached verification the same way a changed body does. + const harness = makePort() + const before = [ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + ] + harness.files.set('resources/DemoProtocol/library.properties', 'name=DemoProtocol\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '#pragma once\n// changed\n') + harness.files.set( + 'build/.verify-cache-library.json', + JSON.stringify({ md5: verifyInputsMd5([], before), success: true }), + ) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(false) + }) + + it('re-verifies when only a native block body changed', async () => { + // The emitted ST for a C/C++ POU is a stub built from its pins, so editing + // the body leaves `program.st` byte-identical. Keying the cache on that + // alone replays the previous result against source that no longer matches. + const harness = makePort() + const before = [{ fileName: 'SmartGate.cpp', source: 'void setup() {}\nvoid loop() {}' }] + const after = 'void setup() {}\nvoid loop() { gate(); }' + harness.files.set( + 'build/.verify-cache-library.json', + JSON.stringify({ md5: verifyInputsMd5(before), success: true }), + ) + harness.files.set('pous/function-blocks/SmartGate.cpp', after) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + nativePous: [{ name: 'SmartGate', relPath: 'pous/function-blocks/SmartGate.cpp', language: 'cpp' }], + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(false) + }) + + it('still skips verification when the native block bodies are unchanged', async () => { + const harness = makePort() + const blocks = [{ fileName: 'SmartGate.cpp', source: 'void setup() {}\nvoid loop() {}' }] + harness.files.set( + 'build/.verify-cache-library.json', + JSON.stringify({ md5: verifyInputsMd5(blocks), success: true }), + ) + harness.files.set('pous/function-blocks/SmartGate.cpp', blocks[0].source) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + nativePous: [{ name: 'SmartGate', relPath: 'pous/function-blocks/SmartGate.cpp', language: 'cpp' }], + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(true) + }) + it('cleanBuild forces a fresh verification regardless of cache', async () => { const harness = makePort() - const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` + const expectedMd5 = verifyInputsMd5() harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { emit } = captureEvents() @@ -700,6 +944,97 @@ describe('runLibraryBuildPipeline', () => { expect(result.error).toMatch(/transpile-from-json failed: transpile-from-json failed/) }) + it('hands the manifest verify target to the port', async () => { + const harness = makePort() + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { + name: 'lib', + version: '0.1.0', + namespace: 'lib', + verifyTarget: { mode: 'arduino', core: 'esp32:esp32' }, + extra: {}, + }, + }) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + expect(harness.verifyCalls[0].target).toEqual({ mode: 'arduino', core: 'esp32:esp32' }) + }) + + it('skips verification entirely, cache included, when the target is off', async () => { + const harness = makePort() + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', verifyTarget: { mode: 'off' }, extra: {} }, + }) + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + expect(harness.files.has('build/.verify-cache-library.json')).toBe(false) + // The `.stlib` still builds — verification was always advisory. + expect(result.success).toBe(true) + expect(result.verification).toBeUndefined() + expect(events.map((e) => e.message)).toEqual( + expect.arrayContaining(['Verification is off in Build Settings — skipping.']), + ) + }) + + it('re-verifies when only the target changed', async () => { + const harness = makePort() + // Cache written for the default target; the project now names a core. + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: verifyInputsMd5(), success: true })) + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { + name: 'lib', + version: '0.1.0', + namespace: 'lib', + verifyTarget: { mode: 'arduino', core: 'esp32:esp32' }, + extra: {}, + }, + }) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + }) + it('treats a thrown verifyCompile as a failed (advisory) verification', async () => { const harness = makePort() // A non-Error throwable exercises the `String(error)` fallback in diff --git a/src/backend/shared/library/build-pipeline.ts b/src/backend/shared/library/build-pipeline.ts index 1c93575a7..5b8a41af6 100644 --- a/src/backend/shared/library/build-pipeline.ts +++ b/src/backend/shared/library/build-pipeline.ts @@ -37,6 +37,8 @@ import type { PLCProject, PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { checkPathId } from '@root/backend/shared/utils/path-safety' import { type KnownPou, splitProgramSt } from '@root/backend/shared/utils/PLC/split-program-st' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { parseVerifyTarget } from '@root/middleware/shared/utils/library/manifest-build-block' import { compileStlib, type CompileStlibError, type CompileStlibSource } from './compile-stlib' @@ -61,6 +63,9 @@ export interface LibraryBuildManifest { name: string version: string namespace: string + /** Toolchain the library is verified with, from the manifest's `build` + * block. Defaults to `{ mode: 'arduino' }` when the block is absent. */ + verifyTarget: LibraryVerifyTarget /** Whatever else was in the JSON. Forwarded to strucpp's * compileStlib via the spread in `composeStlibInputs`, so * upstream additions don't require an editor change. */ @@ -113,6 +118,9 @@ function parseLibraryManifest(json: string): ManifestParseResult { ) } + const verify = parseVerifyTarget(obj) + if ('errors' in verify) errors.push(...verify.errors) + if (errors.length > 0) return { ok: false, errors } return { @@ -121,6 +129,7 @@ function parseLibraryManifest(json: string): ManifestParseResult { name: obj.name as string, version: obj.version as string, namespace: obj.namespace as string, + verifyTarget: (verify as { target: LibraryVerifyTarget }).target, extra: obj, }, } @@ -318,6 +327,26 @@ export interface LibraryNativeSource { source: string } +/** + * One file from the library project's `resources/` directory, carried verbatim + * through the `.stlib` so a library and the sources it was built against ship + * together. + * + * `resources/` holds library folders, so `path` is relative to `resources/` + * and its first segment names the library the file belongs to. The consumer + * reproduces the layout and resolves each folder as a library. + */ +export interface LibraryResource { + path: string + /** UTF-8 text, or the file's bytes base64-encoded when `encoding` says so. */ + content: string + /** Absent for text. `'base64'` marks a file that is not UTF-8 — a library + * that declares `precompiled=true` ships `.a` files beside its headers, and + * those are part of the library. The archive is JSON, so they ride + * encoded and the consumer writes the decoded bytes. */ + encoding?: 'base64' +} + export interface LibraryBuildAux { pouDocs?: Record dependencyArchives?: unknown[] @@ -327,6 +356,9 @@ export interface LibraryBuildAux { * bridge ST is filtered out of the strucpp input in exchange — * see `LibraryNativeSource`. */ nativeSources?: LibraryNativeSource[] + /** Files from the library project's `resources/` tree. Stamped onto the + * archive's `resources` field for the consumer to materialise. */ + resources?: LibraryResource[] } /** @@ -459,6 +491,7 @@ function decorateArchive(archive: unknown, manifest: LibraryBuildManifest, aux: types?: Array<{ name: string; documentation?: string }> } dependencies?: Array<{ name: string; version: string }> + resources?: LibraryResource[] } if (!arch.manifest) return @@ -496,6 +529,13 @@ function decorateArchive(archive: unknown, manifest: LibraryBuildManifest, aux: if (aux?.dependencyRefs && aux.dependencyRefs.length > 0) { arch.dependencies = aux.dependencyRefs.map((ref) => ({ name: ref.name, version: ref.version })) } + + // Resources ride through the archive verbatim. The field stays absent on + // libraries that ship none, so such an archive still loads in an editor + // without them. + if (aux?.resources && aux.resources.length > 0) { + arch.resources = aux.resources.map((r) => ({ path: r.path, content: r.content })) + } } /** @@ -525,10 +565,10 @@ function inferCategory(fileName: string): string | undefined { * (Phase 8) doesn't try to recurse back into the library branch. * * Verification runs the resulting project through the standard - * ST→C++→arduino-cli pipeline against the OpenPLC Simulator - * target. Compile failures there are surfaced as warnings — the - * `.stlib` is still produced (the user may legitimately target a - * platform with more memory than the AVR simulator). + * ST→C++→arduino-cli pipeline against the manifest's verify target. + * Compile failures there are surfaced as warnings — the `.stlib` is still + * produced, because it carries source and the consumer compiles it for its + * own board. */ export function composeVerificationProject(project: PLCProject): PLCProject { const stubbed = stubProgramFor(project) diff --git a/src/backend/shared/library/inject-library-blocks.ts b/src/backend/shared/library/inject-library-blocks.ts index 5d6c068a9..753cfd360 100644 --- a/src/backend/shared/library/inject-library-blocks.ts +++ b/src/backend/shared/library/inject-library-blocks.ts @@ -31,12 +31,16 @@ * * ## Renaming * - * Each block's name is prefixed with the library's manifest name - * (`__`) so two libraries can both ship a `Foo`, and so a + * Each block's name is prefixed with the library's `namespace` + * (`__`) so two libraries can both ship a `Foo`, and so a * consumer's own POU may also be called `Foo`. The library-tree picker * surfaces the prefixed name, so the user authors their ST against it directly * and no source rewriting is needed. * + * The prefix is the NAMESPACE, not `manifest.name`: `name` is checked only for + * path safety, so a hyphenated `my-lib` would yield `my-lib__FOO`, which no ST + * parser accepts. `namespace` is validated as a C++ identifier. + * * Symbol-level renames inside the synthesized POU (the `_VARS` struct, * the `_setup` / `_loop` functions) follow automatically, because * `generateCppSTCode`, `generateCBlocksHeader` and `generateCBlocksCode` all @@ -50,14 +54,34 @@ import type { PLCPou, PLCProjectData } from '../../../middleware/shared/ports/ty /** Separator between the library name and the block name. */ const LIBRARY_BLOCK_SEPARATOR = '__' -/** Build the project-visible POU name for a library block. */ -export function libraryBlockPouName(libraryName: string, blockName: string): string { - return `${libraryName}${LIBRARY_BLOCK_SEPARATOR}${blockName}` +/** + * Build the project-visible POU name for a library block. + * + * `libraryIdentifier` is the manifest's `namespace`, not its `name`: the result + * is parsed as an ST identifier. + */ +export function libraryBlockPouName(libraryIdentifier: string, blockName: string): string { + return `${libraryIdentifier}${LIBRARY_BLOCK_SEPARATOR}${blockName}` +} + +/** + * The identifier an archive's blocks are prefixed with. + * + * `namespace` is required of every manifest this editor builds, and validated + * as a C++ identifier. A foreign archive may lack one, so the name is folded + * into an identifier rather than trusted as it stands. + */ +function libraryIdentifierOf(manifest: { name: string; namespace?: string }): string { + const declared = manifest.namespace + if (declared && /^[A-Za-z_][A-Za-z0-9_]*$/.test(declared)) return declared + const folded = manifest.name.replace(/[^A-Za-z0-9_]/g, '_') + return /^[0-9]/.test(folded) ? `_${folded}` : folded } /** One native block an archive ships, resolved to its authored source. */ type ResolvedNativeBlock = { - libraryName: string + /** Identifier form, for the POU name — see the Renaming note above. */ + libraryIdentifier: string blockName: string language: 'cpp' | 'python' /** The authored file, verbatim — ST header plus native body. */ @@ -98,7 +122,7 @@ function resolveNativeBlocks( continue } blocks.push({ - libraryName, + libraryIdentifier: libraryIdentifierOf(archive.manifest), blockName: entry.name, language: entry.implementation, source, @@ -143,7 +167,7 @@ export function injectLibraryBlocks(projectData: PLCProjectData, archives: Stlib } synthesized.push({ ...parsed, - name: libraryBlockPouName(block.libraryName, block.blockName), + name: libraryBlockPouName(block.libraryIdentifier, block.blockName), pouType: 'function-block', }) } @@ -152,6 +176,40 @@ export function injectLibraryBlocks(projectData: PLCProjectData, archives: Stlib return { ...projectData, pous: [...projectData.pous, ...synthesized] } } +/** + * Every data-type name in scope for a compile: the project's own, plus those + * the enabled libraries declare. + * + * The native bridge spells a pin's type from this set: strucpp declares a + * POU member of a data type as `IEC_`, while a function block instance + * keeps its bare class name, and `mapUserTypeToIEC` tells them apart by + * membership. + * + * A library's types are emitted into the consuming project exactly as its own + * are, so they must be in the set too — built from the project alone, a pin + * typed by a library got `strucpp::MB_SPACE *` against strucpp's own + * `IEC_MB_SPACE`. + */ +export function projectAndLibraryTypeNames( + // Only the two fields this reads, so it takes the port shape and the schema + // shape alike — they differ on `configuration`/`configurations`, which is + // nothing to do with type names. + projectData: { dataTypes?: { name: string }[]; libraries?: { name: string }[] }, + archives: readonly unknown[], +): string[] { + const names = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + + const enabled = new Set((projectData.libraries ?? []).map((ref) => ref.name)) + for (const archive of archives as StlibArchiveDTO[]) { + const libraryName = archive?.manifest?.name + if (!libraryName || !enabled.has(libraryName)) continue + for (const type of archive.manifest.types ?? []) { + names.push(type.name) + } + } + return names +} + /** * Names of enabled libraries that declare native blocks whose source is * missing from the archive. diff --git a/src/backend/shared/library/library-build-orchestrator.ts b/src/backend/shared/library/library-build-orchestrator.ts index cafb2a792..c7f223031 100644 --- a/src/backend/shared/library/library-build-orchestrator.ts +++ b/src/backend/shared/library/library-build-orchestrator.ts @@ -22,7 +22,7 @@ * (see path-constants comment). * 3. Resolve project-enabled library archives + fail on missing * names (one place — feeds BOTH verification and strucpp). - * 4. Verification compile against the OpenPLC Simulator target + * 4. Verification compile against the manifest's verify target * via `LibraryBuildPort.verifyCompile`. MD5 cache hit short- * circuits. Cache record persisted under `build/`. * 5. Gather `pouDocs` from the project data, and read the authored @@ -38,11 +38,18 @@ import type { LibraryBuildPort } from '../../../middleware/shared/ports/library-build-port' import type { CompileLibraryResult } from '../../../middleware/shared/ports/types' +import { + LIBRARY_FOLDER_RULE, + LIBRARY_PROPERTIES, + LIBRARY_SRC_DIR, +} from '../../../middleware/shared/utils/library/library-folder' import type { PLCProject, PLCProjectData } from '../types/PLC/open-plc' +import { isSafeRelativePath } from '../utils/path-safety' import { composeVerificationProject, libraryBuildFromTranspiledSt, type LibraryNativeSource, + type LibraryResource, prepareXmlForLibraryBuild, } from './build-pipeline' import type { NativePouRef } from './native-pou-list' @@ -91,9 +98,80 @@ export interface LibraryBuildArgs { // the user-visible `.stlib` artifact and the verification cache are // written to the project tree. const VERIFY_CACHE_REL_PATH = 'build/.verify-cache-library.json' + +/** Where a library project keeps the files it ships alongside its blocks. */ +const RESOURCES_REL_PATH = 'resources' + const LIBRARY_MANIFEST_REL_PATH = 'library.json' const STLIB_OUT_DIR = 'build' +/** + * Read the library folders under `resources/`, paths intact — the consumer + * reproduces the layout and resolves each folder as a library. + * + * Only the library is read: `library.properties` and everything under `src/`, + * which is what arduino-cli and the runtime Makefile resolve. `library-folder.ts` + * owns the rule, and the picker copies by the same one. + * + * A folder that is not a library fails the build naming it, rather than + * shipping an empty directory that fails at the consumer. + */ +async function readResources( + port: LibraryBuildPort, + projectPath: string, + emit: (event: LibraryBuildEvent) => void, +): Promise<{ resources: LibraryResource[] } | { error: string }> { + const folders = await port.listProjectDirs(projectPath, RESOURCES_REL_PATH) + const resources: LibraryResource[] = [] + for (const folder of folders) { + if (!isSafeRelativePath(folder)) { + return { error: `Resource folder "${folder}" is not a usable folder name.` } + } + const folderPath = `${RESOURCES_REL_PATH}/${folder}` + const properties = await port.readBuildFile(projectPath, `${folderPath}/${LIBRARY_PROPERTIES}`) + const sourcePaths = await port.listProjectFiles(projectPath, `${folderPath}/${LIBRARY_SRC_DIR}`) + + if (properties === null || sourcePaths.length === 0) { + const missing: string[] = [] + if (properties === null) missing.push(LIBRARY_PROPERTIES) + if (sourcePaths.length === 0) missing.push(`${LIBRARY_SRC_DIR}/`) + return { + error: `Resource folder "${folder}" has no ${missing.join(' and no ')} — ${LIBRARY_FOLDER_RULE}.`, + } + } + + resources.push({ path: `${folder}/${LIBRARY_PROPERTIES}`, content: properties }) + for (const sourcePath of sourcePaths) { + const relPath = `${folder}/${LIBRARY_SRC_DIR}/${sourcePath}` + const text = await port.readBuildFile(projectPath, `${RESOURCES_REL_PATH}/${relPath}`) + if (text === null) continue + // A `precompiled=true` library ships a `.a` beside its headers, so it + // travels too — base64, since the archive is JSON. U+FFFD is what + // non-UTF-8 bytes decode to; a text file containing one is carried the + // same way, costing a third of its size. + if (text.includes('\uFFFD')) { + const bytes = await port.readBuildFileBase64(projectPath, `${RESOURCES_REL_PATH}/${relPath}`) + if (bytes === null) continue + resources.push({ path: relPath, content: bytes, encoding: 'base64' }) + continue + } + resources.push({ path: relPath, content: text }) + } + } + + const binaries = resources.filter((resource) => resource.encoding === 'base64') + if (binaries.length > 0) { + // Reported because base64 grows a file by a third and a Runtime v4 upload + // is capped per file and in total. + const kb = Math.round(binaries.reduce((total, resource) => total + resource.content.length, 0) / 1024) + emit({ + message: `Carrying ${binaries.length} binary file(s) from resources, ${kb} KB encoded.`, + level: 'info', + }) + } + return { resources } +} + /** * Run the full library-build pipeline. Pure with respect to its * arguments — every side effect funnels through `port` or `emit`. @@ -191,24 +269,83 @@ export async function runLibraryBuildPipeline( ) } + // ------------------------------------------------------------------------- + // Stage 4b: read the authored C/C++ and Python POU files off the project + // + // Read from disk, NOT from the preprocessed project data: by this point + // `preprocessPous` has replaced every native body with generated bridge ST, + // and the archive must carry what the author wrote. Strucpp stores these + // verbatim and the consumer re-derives the bridge at its own build time — + // that is what keeps a published library working when the bridge changes. + // + // A file that cannot be read fails the build naming the block, rather than + // producing an archive whose manifest promises a block with no source. + // + // Read before verification because the cache key hashes these bodies: a + // native body never reaches `program.st` — the emitted ST is a bridge stub + // built from the pins — so hashing that alone replays a stale result after + // an author edits a block. + // ------------------------------------------------------------------------- + const nativeSources: LibraryNativeSource[] = [] + for (const ref of nativePous) { + const fileName = ref.relPath.split('/').pop() ?? ref.name + let source: string | null + try { + source = await port.readBuildFile(projectPath, ref.relPath) + } catch (error) { + return fail(emit, `Could not read "${ref.relPath}": ${formatError(error)}`, { libraryName: manifest.name }) + } + if (source === null || source.trim() === '') { + return fail( + emit, + `Could not read the source for "${ref.name}" at ${ref.relPath}. ` + + 'C/C++ and Python blocks ship their source verbatim, so the file must be present.', + { libraryName: manifest.name }, + ) + } + nativeSources.push({ fileName, source }) + } + // ------------------------------------------------------------------------- // Stage 5: verification compile // - // Hash program.st and consult the cache; cache hit short-circuits - // the slow avr-gcc compile. cleanBuild forces a fresh run. + // Hash the verified sources and consult the cache; cache hit + // short-circuits the slow compile. cleanBuild forces a fresh run. // Verification failures are advisory: they surface as warnings on // `verification.success` with the build still producing a `.stlib`. // + // The key covers the C/C++ block bodies and the resources as well as + // `program.st`. A block's body never reaches `program.st` — the emitted ST + // is a stub built from its pins — so hashing that alone replays a stale + // result after a body or a resource changes. + // // The MD5 routes through the platform port instead of `node:crypto` // so the shared module ships without a host-runtime dependency. // Editor's port wires it to Node's hash; web's port wires it to // spark-md5 — both produce byte-identical output. + // + // `build.verify: "off"` in the manifest skips the whole stage, cache + // included: a library whose C++ targets no toolchain the editor can drive + // would otherwise carry a permanent failure that reports nothing. // ------------------------------------------------------------------------- - const programStMd5 = await port.computeMd5(programSt) + const resourcesRead = await readResources(port, projectPath, emit) + if ('error' in resourcesRead) { + return fail(emit, resourcesRead.error, { libraryName: manifest.name }) + } + const resources = resourcesRead.resources + const verifyTarget = manifest.verifyTarget + const nativeSource = nativeSources.map((n) => `${n.fileName}\n${n.source}`).join('\n') + const resourceSource = resources.map((r) => `${r.path}\n${r.content}`).join('\n') + // The target is part of the key: the same sources verified against a + // different toolchain are a different question. + const targetSource = `${verifyTarget.mode}\n${verifyTarget.core ?? ''}` + const verifyInputsMd5 = await port.computeMd5(`${programSt}\n${nativeSource}\n${resourceSource}\n${targetSource}`) let verification: CompileLibraryResult['verification'] let usedCache = false - if (!cleanBuild) { - const cached = await readVerificationCache(port, projectPath, programStMd5) + if (verifyTarget.mode === 'off') { + emit({ message: 'Verification is off in Build Settings — skipping.', level: 'info' }) + } else if (!cleanBuild) { + const cached = await readVerificationCache(port, projectPath, verifyInputsMd5) if (cached) { verification = cached usedCache = true @@ -218,16 +355,19 @@ export async function runLibraryBuildPipeline( }) } } - if (!verification) { + if (!verification && verifyTarget.mode !== 'off') { const verifyProject = composeVerificationProject({ meta: { name: manifest.name, type: 'plc-library' }, data: verifyProjectData, }) - emit({ message: 'Verifying with OpenPLC Simulator (avr-gcc)...', level: 'info' }) + emit({ message: 'Verifying library compile...', level: 'info' }) try { verification = await port.verifyCompile({ projectPath, - verifyProjectData: verifyProject.data, + // A library project does not list itself, so its resources have to be + // handed over explicitly for its own blocks to resolve their includes. + verifyProjectData: { ...verifyProject.data, ownLibraryResources: resources } as PLCProjectData, + target: verifyTarget, emit: (message, logLevel) => { // Demote inner errors to warnings on the way out. `.stlib` // is still produced, so an error-level `[verify]` line in @@ -253,7 +393,7 @@ export async function runLibraryBuildPipeline( await port.writeBuildFile( projectPath, VERIFY_CACHE_REL_PATH, - JSON.stringify({ md5: programStMd5, ...verification }, null, 2), + JSON.stringify({ md5: verifyInputsMd5, ...verification }, null, 2), ) } catch (cacheErr) { emit({ message: `Could not write verification cache: ${formatError(cacheErr)}`, level: 'warning' }) @@ -283,38 +423,6 @@ export async function runLibraryBuildPipeline( pouDocs[name] = doc } } - // ------------------------------------------------------------------------- - // Stage 6b: read the authored C/C++ and Python POU files off the project - // - // Read from disk, NOT from the preprocessed project data: by this point - // `preprocessPous` has replaced every native body with generated bridge ST, - // and the archive must carry what the author wrote. Strucpp stores these - // verbatim and the consumer re-derives the bridge at its own build time — - // that is what keeps a published library working when the bridge changes. - // - // A file that cannot be read fails the build naming the block, rather than - // producing an archive whose manifest promises a block with no source. - // ------------------------------------------------------------------------- - const nativeSources: LibraryNativeSource[] = [] - for (const ref of nativePous) { - const fileName = ref.relPath.split('/').pop() ?? ref.name - let source: string | null - try { - source = await port.readBuildFile(projectPath, ref.relPath) - } catch (error) { - return fail(emit, `Could not read "${ref.relPath}": ${formatError(error)}`, { libraryName: manifest.name }) - } - if (source === null || source.trim() === '') { - return fail( - emit, - `Could not read the source for "${ref.name}" at ${ref.relPath}. ` + - 'C/C++ and Python blocks ship their source verbatim, so the file must be present.', - { libraryName: manifest.name }, - ) - } - nativeSources.push({ fileName, source }) - } - // ------------------------------------------------------------------------- // Stage 7: strucpp compileStlib // ------------------------------------------------------------------------- @@ -324,6 +432,7 @@ export async function runLibraryBuildPipeline( dependencyArchives: depArchives, dependencyRefs: enabledLibraryRefs, nativeSources, + resources, }) if (!stage7.success) { for (const err of stage7.errors) { @@ -363,14 +472,14 @@ export async function runLibraryBuildPipeline( /** * Read + validate the verification cache. Returns the cached * `{ success, message }` only when the persisted MD5 matches the - * current `programSt`. Malformed cache files and missing files are + * sources being verified. Malformed cache files and missing files are * indistinguishable from a fresh build — both return null so the * caller falls through to a real verification run. */ async function readVerificationCache( port: LibraryBuildPort, projectPath: string, - programStMd5: string, + verifyInputsMd5: string, ): Promise<{ success: boolean; message?: string } | null> { let raw: string | null try { @@ -381,7 +490,7 @@ async function readVerificationCache( if (raw === null) return null try { const parsed = JSON.parse(raw) as { md5?: string; success?: boolean; message?: string } - if (parsed?.md5 === programStMd5 && typeof parsed.success === 'boolean') { + if (parsed?.md5 === verifyInputsMd5 && typeof parsed.success === 'boolean') { return { success: parsed.success, message: parsed.message } } } catch { diff --git a/src/backend/shared/project/__tests__/create-project-files.test.ts b/src/backend/shared/project/__tests__/create-project-files.test.ts index b5670f2a6..ee7737fc6 100644 --- a/src/backend/shared/project/__tests__/create-project-files.test.ts +++ b/src/backend/shared/project/__tests__/create-project-files.test.ts @@ -99,6 +99,10 @@ describe('buildProjectFileContent', () => { expect(built.libraryManifest).toBeUndefined() }) + it('does not emit a resources README — a program has no resources', () => { + expect(built.libraryResourcesReadme).toBeUndefined() + }) + describe('default POU body per language', () => { it('seeds a ladder rung container for LD projects', () => { const ld = buildProjectFileContent({ name: 'P', type: 'plc-project', language: 'ld', time: 'T#20ms' }) @@ -143,6 +147,14 @@ describe('buildProjectFileContent', () => { expect(built.project.meta.type).toBe('plc-library') }) + it('emits a resources README describing the folder-per-library layout', () => { + // `resources/` is created empty, and an empty directory does not survive + // a commit, so the README is what carries the convention to the author. + expect(built.libraryResourcesReadme).toBeDefined() + expect(built.libraryResourcesReadme).toContain('library.properties') + expect(built.libraryResourcesReadme).toContain('src/') + }) + it('emits a library manifest with snake_case namespace auto-fill', () => { expect(built.libraryManifest).toBeDefined() const manifest = JSON.parse(built.libraryManifest as string) as Record diff --git a/src/backend/shared/project/create-project-files.ts b/src/backend/shared/project/create-project-files.ts index d84155ecc..058dff058 100644 --- a/src/backend/shared/project/create-project-files.ts +++ b/src/backend/shared/project/create-project-files.ts @@ -35,6 +35,42 @@ export interface CreateProjectFileInput { * to persist. `pous` carries the editor-flat shape (one PLCPou per * file) so callers don't re-derive it. */ +/** + * Written to `resources/README.md` when a library project is created. The + * directory is otherwise empty, and an empty directory does not survive a + * commit, so the file is what carries the convention to the author. + */ +const LIBRARY_RESOURCES_README = `# Resources + +Put the C/C++ libraries your blocks need in here, one folder each: + +\`\`\` +resources/ + MyLibrary/ + library.properties + src/ + MyLibrary.h + MyLibrary.cpp +\`\`\` + +That is the ordinary Arduino library layout, so in most cases you can copy a +library folder in as it stands. Build Settings, under Manifest in the project +tree, adds one for you and lists what is here. + +Everything in here is packaged into the \`.stlib\` when you build. A project +that installs your library gets these sources unpacked into its own build and +compiled for its own target, so there is nothing for the user to install +separately and the sources can never fall out of step with your blocks. + +## Notes + +- Sources may nest as deeply as you like under \`src/\`. +- A folder without a \`library.properties\` still works; one is generated. +- A file sitting loose in \`resources/\`, outside any folder, belongs to no + library and is skipped with a warning. +- Only text files are carried. Pre-compiled binaries are not. +` + export interface CreateProjectFileContent { project: PLCProject pous: PLCPou[] @@ -44,6 +80,9 @@ export interface CreateProjectFileContent { * that the editor writes alongside `project.json`. Pre-filled with * snake_case namespace, version `0.1.0`, empty symbol arrays. */ libraryManifest?: string + /** README written into a library project's `resources/` directory, + * explaining what the folder is for. Library projects only. */ + libraryResourcesReadme?: string } /** @@ -210,6 +249,11 @@ export function buildProjectFileContent(input: CreateProjectFileInput): CreatePr pous, deviceConfiguration, devicePinMapping, - ...(isLibrary ? { libraryManifest: buildLibraryManifestTemplate(input.name) } : {}), + ...(isLibrary + ? { + libraryManifest: buildLibraryManifestTemplate(input.name), + libraryResourcesReadme: LIBRARY_RESOURCES_README, + } + : {}), } } diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts index 37bfd1ae6..42aad3281 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts @@ -67,6 +67,11 @@ export function generateTextualPou(pou: TranspilePou, project: TranspileProject, program.push([' : ', []]) program.push([formatReturnType(pou.interface.returnType), [tagName, 'return']]) } + // Dropped here, a derived block reaches strucpp with no base clause, so it + // is emitted with no base class, no inherited pins and no dynamic binding. + if (pou.interface.extends) { + program.push([` EXTENDS ${pou.interface.extends}`, [tagName, 'extends']]) + } program.push(['\n', []]) const iface = computeInterface(pou.interface.variables) diff --git a/src/backend/shared/transpilers/st-transpiler/from-schema.ts b/src/backend/shared/transpilers/st-transpiler/from-schema.ts index 4d635c4af..593013787 100644 --- a/src/backend/shared/transpilers/st-transpiler/from-schema.ts +++ b/src/backend/shared/transpilers/st-transpiler/from-schema.ts @@ -139,6 +139,11 @@ function projectPou(pou: SchemaPou): TranspilePou { interface: { variables, ...(pou.type === 'function' ? { returnType: stringifyReturnType(pou.data.returnType) } : {}), + // Carried across, or the compile path emits the derived block with no + // base. Narrowed on `pou.type`: only that variant of the union carries it. + ...(pou.type === 'function-block' && pou.data.extends + ? { extends: pou.data.extends } + : {}), }, body: projectBody(pou.data.body), } diff --git a/src/backend/shared/transpilers/st-transpiler/types.ts b/src/backend/shared/transpilers/st-transpiler/types.ts index 02d50c2d3..5d9327251 100644 --- a/src/backend/shared/transpilers/st-transpiler/types.ts +++ b/src/backend/shared/transpilers/st-transpiler/types.ts @@ -45,6 +45,8 @@ export interface TranspilePou { export interface TranspilePouInterface { /** Only set on `function` POUs. */ returnType?: string + /** Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. */ + extends?: string variables: TranspileVariable[] } diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 25b4ef8b3..80a378ba6 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -241,6 +241,12 @@ type PLCInstance = z.infer const PLCFunctionBlockSchema = z.object({ language: z.enum(['il', 'st', 'ld', 'sfc', 'fbd', 'python', 'cpp']), name: z.string(), + /** + * Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. Only a function + * block may extend another, so this sits here and not on the FUNCTION or + * PROGRAM schema. + */ + extends: z.string().optional(), /** Array of variable - will be implemented */ variables: z.array(PLCVariableSchema), body: bodySchema, diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index b4a817e5e..c7c3590b1 100644 --- a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts +++ b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts @@ -1,7 +1,7 @@ import type { PLCVariable } from '../../../../../middleware/shared/ports/types' import { generateCBlocksCode } from '../generateCBlocksCode' -const makeScalarVar = (name: string, cls: 'input' | 'output', baseType: string): PLCVariable => ({ +const makeScalarVar = (name: string, cls: 'input' | 'output' | 'inOut', baseType: string): PLCVariable => ({ name, class: cls, type: { definition: 'base-type', value: baseType }, @@ -285,4 +285,52 @@ describe('generateCBlocksCode', () => { expect(result).toContain('#undef ioVar') expect(result).not.toContain('#undef hasBeenInitialized') }) + + it('does not alias a generic pin as if it were a project type', () => { + // A generic pin is a `user-data-type` by shape but names no project type: + // it resolves to the runtime's own IEC_ANY. Aliasing it emitted + // `using ANY_INT = strucpp::ANY_INT;` for a type that does not exist, and + // only a block declaring one would find out. + const result = generateCBlocksCode([ + { + name: 'SCALE', + code: 'void setup() { }\nvoid loop() { }', + variables: [ + { + name: 'raw', + class: 'input', + type: { definition: 'user-data-type', value: 'ANY_INT' }, + location: '', + documentation: '', + debug: false, + }, + ], + }, + ]) + + expect(result).not.toContain('using ANY_INT') + expect(result).not.toContain('strucpp::ANY_INT') + }) + + it('still aliases a real project type', () => { + const result = generateCBlocksCode([ + { + name: 'DRIVE', + code: 'void setup() { }\nvoid loop() { }', + variables: [ + { + name: 'motor', + class: 'input', + type: { definition: 'user-data-type', value: 'MOTOR' }, + location: '', + documentation: '', + debug: false, + }, + ], + }, + ]) + + expect(result).toContain('using MOTOR = strucpp::MOTOR;') + }) + }) diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts index 4e0c18b49..a317d766e 100644 --- a/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts +++ b/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts @@ -1,7 +1,7 @@ import type { PLCVariable } from '../../../../../middleware/shared/ports/types' import { generateCBlocksHeader } from '../generateCBlocksHeader' -const makeScalarVar = (name: string, cls: 'input' | 'output', baseType: string): PLCVariable => ({ +const makeScalarVar = (name: string, cls: 'input' | 'output' | 'inOut', baseType: string): PLCVariable => ({ name, class: cls, type: { definition: 'base-type', value: baseType }, diff --git a/src/backend/shared/utils/cpp/generateCBlocksCode.ts b/src/backend/shared/utils/cpp/generateCBlocksCode.ts index 7f17c813e..2bf9b0700 100644 --- a/src/backend/shared/utils/cpp/generateCBlocksCode.ts +++ b/src/backend/shared/utils/cpp/generateCBlocksCode.ts @@ -1,5 +1,9 @@ import { cBlockExternalVariables, cBlockInterfaceVariables } from '../../../../frontend/utils/cpp/block-interface' -import { isArrayVariable, multiDimensionalContainerType } from '../../../../frontend/utils/PLC/array-codegen-helpers' +import { + isArrayVariable, + isDescriptorPinType, + multiDimensionalContainerType, +} from '../../../../frontend/utils/PLC/array-codegen-helpers' import type { PLCVariable } from '../../../../middleware/shared/ports/types' type CppPouData = { @@ -168,7 +172,14 @@ const generateUserTypeAliases = (cppPous: CppPouData[], userTypeNames: Iterable< // `derived` is a function block instance, `user-data-type` a structure or // enumeration. A block may name either — casting to an enumeration, or // declaring a local of an FB class — so both belong in scope. - if (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') { + // A generic pin looks like a user type by name, but names no project + // type: it resolves to the runtime's own `IEC_ANY`, so aliasing it would + // emit `using ANY_INT = strucpp::ANY_INT;` for a type that does not + // exist. + if ( + (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') && + !isDescriptorPinType(variable.type.value) + ) { referenced.add(variable.type.value.toUpperCase()) } if (variable.type.definition === 'array' && variable.type.data?.baseType.definition === 'user-data-type') { diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 050dd4f49..d4ef71373 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -290,6 +290,7 @@ function parsePouFile(file: RawProjectFile, warnings: string[]): (PLCPou & { var pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType as string | undefined, + ...(ipcPou.data.extends ? { extends: ipcPou.data.extends as string } : {}), variables: (ipcPou.data.variables as PLCVariable[]) ?? [], }, body: ipcPou.data.body as PLCPou['body'], diff --git a/src/backend/shared/utils/path-safety.ts b/src/backend/shared/utils/path-safety.ts index f8c02223d..a4ecf520c 100644 --- a/src/backend/shared/utils/path-safety.ts +++ b/src/backend/shared/utils/path-safety.ts @@ -66,3 +66,19 @@ export function validatePathId(id: string, fieldName: string): void { const error = checkPathId(id, fieldName) if (error !== null) throw new Error(error) } + +/** + * True when `value` is safe to materialise under a build directory: relative, + * with no `..` segment, no drive letter and no control characters. + * + * Library resources carry their own paths through the `.stlib`, so an archive + * is untrusted input by the time a consuming project unpacks it. + */ +export function isSafeRelativePath(value: string): boolean { + if (value.length === 0) return false + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f]/.test(value)) return false + if (value.startsWith('/') || value.startsWith('\\')) return false + if (/^[a-zA-Z]:/.test(value)) return false + return value.split(/[\\/]/).every((segment) => segment !== '' && segment !== '..') +} diff --git a/src/cli/__tests__/library.test.ts b/src/cli/__tests__/library.test.ts new file mode 100644 index 000000000..54696a01e --- /dev/null +++ b/src/cli/__tests__/library.test.ts @@ -0,0 +1,82 @@ +/** + * `openplc-cli library` — argument handling and result shaping. + * + * The heavy paths (a real build, a real install) belong to the modules this + * command drives and are covered where they live. What is tested here is the + * part the command owns: which subcommands exist, what a missing argument + * reports, and the shape of what comes back. + */ + +import { parseArgs } from '../args' +import { runLibrary } from '../commands/library' +import { ErrorCode, ExitCode } from '../exit-codes' +import { Reporter, type WriterStreams } from '../output' + +// The compiler drags in the hardware and package-manager modules, which want +// Electron's `app`. Nothing here builds, so it is stubbed rather than loaded. +jest.mock('@root/backend/editor/compiler', () => ({ + CompilerModule: jest.fn().mockImplementation(() => ({ compileLibrary: jest.fn() })), +})) + +jest.mock('../project/load', () => ({ + loadProject: jest.fn(), +})) + +jest.mock('@root/backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn().mockImplementation(() => ({ + listInstalled: () => [ + { name: 'modbee-protocol', version: '0.1.0', bundled: false, installedAt: '', origin: 'stlib' }, + ], + installFromFile: jest.fn(), + loadAll: () => [], + loadEnabledArchives: () => ({ archives: [], missing: [] }), + })), +})) + +function capture(): { streams: WriterStreams; out: string[]; err: string[] } { + const out: string[] = [] + const err: string[] = [] + return { streams: { out: (t) => out.push(t), err: (t) => err.push(t) }, out, err } +} + +const run = async (argv: string[]) => { + const { streams, out } = capture() + const reporter = new Reporter({ mode: 'json', streams }) + const result = await runLibrary(parseArgs(argv), reporter) + return { result, payload: out.length > 0 ? JSON.parse(out[0]) : undefined } +} + +describe('openplc-cli library', () => { + it('names the three subcommands when given none', async () => { + const { result, payload } = await run(['library']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.code).toBe(ErrorCode.InvalidArgument) + expect(payload.error.message).toContain('build, install or list') + }) + + it('rejects an unknown subcommand rather than guessing', async () => { + const { result, payload } = await run(['library', 'publish']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('publish') + }) + + it('asks for a project path when build is given none', async () => { + const { result, payload } = await run(['library', 'build']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('library project') + }) + + it('asks for a file when install is given none', async () => { + const { result, payload } = await run(['library', 'install']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('.stlib') + }) + + it('lists what the library manager reports as installed', async () => { + const { result, payload } = await run(['library', 'list']) + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.ok).toBe(true) + expect(payload.libraries).toHaveLength(1) + expect(payload.libraries[0].name).toBe('modbee-protocol') + }) +}) diff --git a/src/cli/commands/library.ts b/src/cli/commands/library.ts new file mode 100644 index 000000000..c190ac790 --- /dev/null +++ b/src/cli/commands/library.ts @@ -0,0 +1,257 @@ +/** + * `openplc-cli library` — build a Library Project into a `.stlib`, install one, + * and list what is installed. + * + * All three were GUI-only. Building ran through `CompilerModule.compileLibrary` + * over a MessagePort from the renderer; installing through + * `LibraryManagerModule`, which writes `/libraries//` AND a + * `registry.json` beside it — so copying an archive into place installs nothing. + * + * `build` enters `compileLibrary` at the same point the main process does, with + * the preprocessing the renderer's adapter does first. Reassembling those steps + * here would build a *different library* from the same sources, which is the + * kind of divergence that makes a green build worthless. + */ + +import { CompilerModule } from '@root/backend/editor/compiler' +import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import { collectNativePous } from '@root/backend/shared/library/native-pou-list' +import { preprocessPous } from '@root/backend/shared/utils/PLC/preprocess-pous' +import { toIpcProjectData } from '@root/middleware/adapters/editor/compiler-adapter' +import type { CompileLibraryResult, PLCProjectData } from '@root/middleware/shared/ports/types' + +import { boolFlag, type ParsedArgs } from '../args' +import { createHeadlessCompileBridge, createProgressChannel } from '../compile/headless-bridge' +import { ErrorCode, ExitCode } from '../exit-codes' +import { type CliResult, renderTable, type Reporter } from '../output' +import { loadProject } from '../project/load' + +export async function runLibrary(args: ParsedArgs, reporter: Reporter): Promise { + const [subcommand, target] = args.positionals + + switch (subcommand) { + case 'build': + return runLibraryBuild(args, reporter, target) + case 'install': + return runLibraryInstall(reporter, target) + case 'list': + return runLibraryList(reporter) + default: + return reporter.failure( + { + code: ErrorCode.InvalidArgument, + message: `library takes build, install or list — got "${subcommand ?? ''}".`, + }, + ExitCode.Usage, + ) + } +} + +async function runLibraryBuild( + args: ParsedArgs, + reporter: Reporter, + projectPath: string | undefined, +): Promise { + if (!projectPath) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library build needs the path of a library project.' }, + ExitCode.Usage, + ) + } + + const loaded = await loadProject(projectPath) + if (!loaded.success) { + return reporter.failure({ code: ErrorCode.ProjectNotFound, message: loaded.error }, ExitCode.NotFound) + } + for (const warning of loaded.project.warnings) reporter.progress(warning) + + const prepared = prepareLibraryData(loaded.project.data, reporter) + if ('error' in prepared) { + return reporter.failure({ code: ErrorCode.CompileFailed, message: prepared.error }, ExitCode.CompileFailed) + } + + reporter.progress(`Building library at ${loaded.project.projectPath}…`) + const result = await compileLibrary({ + projectPath: loaded.project.projectPath, + buildData: prepared.buildData, + verifyData: prepared.verifyData, + cleanBuild: boolFlag(args, 'clean'), + nativePous: prepared.nativePous, + onMessage: (message, level) => reporter.progress(` ${level === 'info' ? '' : `${level}: `}${message}`), + }) + + if (!result.success) { + return reporter.failure( + { code: ErrorCode.CompileFailed, message: result.error ?? 'Library build failed.' }, + ExitCode.CompileFailed, + ) + } + + return reporter.success( + { + ok: true, + library: result.libraryName, + stlibPath: result.stlibPath, + verification: result.verification ?? null, + }, + () => + [ + `Built ${result.libraryName ?? 'library'}`, + result.stlibPath ? ` ${result.stlibPath}` : '', + ` verification: ${describeVerification(result.verification)}`, + ] + .filter(Boolean) + .join('\n'), + ) +} + +/** + * The two `preprocessPous` passes the renderer's adapter runs, and the native + * POU list taken before them. + * + * The build pass keeps Python POUs as real code; the verification pass stubs + * them, because the simulator it compiles against has no interpreter. The + * native list has to be collected first: preprocessing lowers every native body + * to bridge ST and rewrites its language tag, leaving nothing to identify one + * by afterwards. + */ +function prepareLibraryData( + projectData: PLCProjectData, + reporter: Reporter, +): + | { buildData: PLCProjectData; verifyData: PLCProjectData; nativePous: ReturnType } + | { error: string } { + const nativePous = collectNativePous(projectData) + + // A library's own POU may hold a function block instance, so preprocessing + // needs the same pin sources a project build gets. + const fbSources = new LibraryManagerModule().loadAll().map((archive) => ({ + functionBlocks: archive.manifest.functionBlocks, + })) + + const buildPass = preprocessPous( + projectData, + false, + (level, message) => reporter.progress(` ${level === 'info' ? '' : `${level}: `}${message}`), + undefined, + fbSources, + ) + if (buildPass.validationFailed) { + return { error: buildPass.validationError ?? VALIDATION_FALLBACK } + } + + // Silent: the same project already logged its POUs on the build pass. + const verifyPass = preprocessPous(projectData, true, () => undefined, undefined, fbSources) + if (verifyPass.validationFailed) { + return { error: verifyPass.validationError ?? VALIDATION_FALLBACK } + } + + return { buildData: buildPass.projectData, verifyData: verifyPass.projectData, nativePous } +} + +const VALIDATION_FALLBACK = 'POU validation failed. Check C/C++ blocks for missing setup()/loop() functions.' + +/** + * Drive `CompilerModule.compileLibrary` over a plain channel. + * + * The protocol is the main process's: log messages arrive one at a time, then + * one message carrying `libraryBuildResult`, then the channel closes. The close + * is the only "done" signal, so the result is held until it arrives. + */ +function compileLibrary(options: { + projectPath: string + buildData: PLCProjectData + verifyData: PLCProjectData + cleanBuild: boolean + nativePous: ReturnType + onMessage: (message: string, level: 'info' | 'warning' | 'error') => void +}): Promise { + return new Promise((resolve) => { + let result: CompileLibraryResult | undefined + + const channel = createProgressChannel({ + onMessage: (message: unknown) => { + if (typeof message !== 'object' || message === null) return + const payload = message as Record + if (payload.libraryBuildResult) { + result = payload.libraryBuildResult as CompileLibraryResult + return + } + if (typeof payload.message === 'string') { + const level = payload.logLevel === 'warning' || payload.logLevel === 'error' ? payload.logLevel : 'info' + options.onMessage(payload.message, level) + } + }, + onClose: () => resolve(result ?? { success: false, error: 'Library build closed without a result.' }), + }) + + void new CompilerModule() + .compileLibrary( + // Positional, as the main process receives them over IPC: + // [projectPath, build-pass data, verify-pass data, cleanBuild, nativePous]. + // + // Shaped by `toIpcProjectData`, not passed as-is: the IPC form renames + // `configurations` to `configuration`, which the build pipeline reads. + [ + options.projectPath, + toIpcProjectData(options.buildData) as never, + toIpcProjectData(options.verifyData) as never, + options.cleanBuild, + options.nativePous as never, + ], + channel, + createHeadlessCompileBridge(null), + ) + .catch((error: unknown) => { + result = { success: false, error: error instanceof Error ? error.message : String(error) } + channel.close() + }) + }) +} + +function describeVerification(verification: CompileLibraryResult['verification']): string { + if (!verification) return 'not run' + return verification.success ? 'passed' : `failed — ${verification.message ?? 'see log'}` +} + +async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefined): Promise { + if (!stlibPath) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library install needs the path of a .stlib file.' }, + ExitCode.Usage, + ) + } + + const result = await new LibraryManagerModule().installFromFile(stlibPath) + if (!result.success) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: result.error }, + ExitCode.TargetError, + ) + } + if (result.canceled) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: `Nothing installed from ${stlibPath}.` }, + ExitCode.TargetError, + ) + } + + return reporter.success( + { ok: true, library: result.name, version: result.version, origin: result.origin }, + () => `Installed ${result.name} ${result.version}`, + ) +} + +function runLibraryList(reporter: Reporter): CliResult { + const installed = new LibraryManagerModule().listInstalled() + return reporter.success( + { ok: true, libraries: installed }, + () => + installed.length === 0 + ? 'No libraries installed.' + : renderTable( + ['Name', 'Version', 'Origin'], + installed.map((library) => [library.name, library.version, library.origin]), + ), + ) +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a124a518d..7033ddc26 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -35,6 +35,7 @@ import { runCreate } from './commands/create' import { type DebugContext, runDebug } from './commands/debug' import { runDevices } from './commands/devices' import { runInstallCli } from './commands/install-cli' +import { runLibrary } from './commands/library' import { runDaemonFromStdin } from './daemon-entry' import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' import { createProcessReporter, Reporter } from './output' @@ -79,6 +80,9 @@ Usage openplc-cli create --from-json (fixture-friendly form) openplc-cli install-cli (put openplc-cli on your PATH) openplc-cli devices [--timeout ] + openplc-cli library build [--clean] + openplc-cli library install + openplc-cli library list openplc-cli compile [--target ] [--port ] [--clean] openplc-cli upload (--host
| --port ) [--target ] [--clean] [-y|--yes] openplc-cli debug open --target (--host
| --port ) [--upload-if-needed] @@ -201,6 +205,8 @@ async function dispatch(args: ParsedArgs, reporter: Reporter): Promise arg.endsWith('.js')) ?? __filename if (!script.endsWith('.js')) { throw new Error( `Cannot locate the CLI bundle to spawn a debug session (resolved "${script}"). ` + diff --git a/src/frontend/components/_atoms/string-length-menu-item/index.tsx b/src/frontend/components/_atoms/string-length-menu-item/index.tsx new file mode 100644 index 000000000..2d6089617 --- /dev/null +++ b/src/frontend/components/_atoms/string-length-menu-item/index.tsx @@ -0,0 +1,78 @@ +import * as PrimitiveDropdown from '@radix-ui/react-dropdown-menu' + +import { cn } from '../../../utils/cn' +import { MAX_STRING_LENGTH, parseStringLength } from '../../../utils/iec-types-registry' +import { InputWithRef } from '../input' + +type IStringLengthMenuItemProps = { + /** Canonical, upper-cased: STRING or WSTRING. */ + typeName: string + /** Current contents of the length box, as typed. */ + length: string + onLengthChange: (next: string) => void + /** Called with `STRING` or `STRING(23)` once the row is applied. */ + onApply: (declaredType: string) => void + /** Class for the label, so each menu keeps its own type scale. */ + labelClassName?: string +} + +/** + * A STRING or WSTRING row in a type menu, with its declared length beside it. + * An empty box selects the unqualified type. + */ +export const StringLengthMenuItem = ({ + typeName, + length, + onLengthChange, + onApply, + labelClassName = 'font-caption text-xs font-normal text-neutral-700 dark:text-neutral-500', +}: IStringLengthMenuItemProps) => { + const trimmed = length.trim() + const declaredType = trimmed === '' ? typeName : `${typeName}(${trimmed})` + const valid = trimmed === '' || parseStringLength(declaredType).valid + + return ( + { + // Refuse rather than silently apply the unqualified type. + if (!valid) { + event.preventDefault() + return + } + onApply(declaredType) + }} + className='flex h-8 w-full cursor-pointer items-center justify-center gap-1 py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + {typeName} + onLengthChange(e.target.value)} + // Radix routes typing to its typeahead and Space/Enter to selection, so + // digits reach the box only if the keystroke stops here. + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && valid) onApply(declaredType) + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + /> + + ) +} + +/** Seed the length box from a declaration already on the variable. */ +export const seedStringLengths = (declaredType: unknown): Record => { + const { base, length, valid } = parseStringLength(String(declaredType ?? '')) + return length !== undefined && valid ? { [base]: String(length) } : {} +} diff --git a/src/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index 9bda126c6..2146ef3ad 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -59,6 +59,7 @@ const TabIcons: Record = { 'ethercat-device': , 'library-manager': , 'library-manifest': , + 'build-settings': , 'user-management': , 'diff-viewer': , } @@ -92,6 +93,7 @@ const Tab = (props: ITabProps) => { | 'ethercat-device' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'diff-viewer' = 'il' @@ -132,6 +134,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'library-manifest') { languageOrDerivation = 'library-manifest' } + if (fileDerivation?.type === 'build-settings') { + languageOrDerivation = 'build-settings' + } if (fileDerivation?.type === 'user-management') { languageOrDerivation = 'user-management' } diff --git a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx index 7f75d4e41..571764b6b 100644 --- a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx +++ b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx @@ -3,7 +3,9 @@ import _ from 'lodash' import { useState } from 'react' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' +import { isLengthQualifiedType } from '../../../utils/iec-types-registry' import { DropdownSearchInput } from '../dropdown-search-input' +import { seedStringLengths, StringLengthMenuItem } from '../string-length-menu-item' type TypeDropdownSelectorProps = { value: string @@ -25,12 +27,13 @@ export const TypeDropdownSelector = ({ 'base-type': '', 'user-data-type': '', }) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) return (
- {value ? _.upperCase(value) : 'Select...'} + {value ? value.toUpperCase() : 'Select...'}
@@ -72,17 +75,31 @@ export const TypeDropdownSelector = ({ } /> {filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - onSelect(scope.definition as 'base-type' | 'user-data-type', value)} - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((entry) => + isLengthQualifiedType(entry) ? ( + + setStringLengths((prev) => ({ ...prev, [entry.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as 'base-type' | 'user-data-type', declaredType) + } + /> + ) : ( + onSelect(scope.definition as 'base-type' | 'user-data-type', entry)} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {entry.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_features/[workspace]/build-options/index.tsx b/src/frontend/components/_features/[workspace]/build-options/index.tsx index 670191f51..b69966cc9 100644 --- a/src/frontend/components/_features/[workspace]/build-options/index.tsx +++ b/src/frontend/components/_features/[workspace]/build-options/index.tsx @@ -138,7 +138,7 @@ export const BuildOptionsPopover = ({ /> choose('clean-upload')} diff --git a/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx b/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx new file mode 100644 index 000000000..60133feae --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx @@ -0,0 +1,143 @@ +/** + * Build Settings screen — a Library Project's verify target and the library + * folders it ships in `resources/`. + * + * Two tabs, same shape as the Library Manager (which is itself the EtherCAT + * editor's tab structure over the package manager's card layout): + * + * - **Verify Target** — which toolchain the library is checked with. + * Stored in `library.json`'s `build` block, because a library project has + * `hasDevices: false` and so has no device screen to hang it on. Edits go + * to the same store field the Manifest tab is bound to, so the two stay + * in step and the change saves with the project. + * + * - **Resources** — the library folders packaged into the `.stlib`. These + * are files on disk, so add and remove take effect as they are made — the + * same way the Library Manager installs an archive. + * + * Named Build Settings rather than Build Options because arduino-cli already + * owns "build options" (`--build-property`, `build.options.json`), as does + * the editor's own build-options popover. + */ + +import * as Tabs from '@radix-ui/react-tabs' +import { useOpenPLCStore } from '@root/frontend/store' +import { LIBRARY_MANIFEST_TAB_NAME } from '@root/frontend/store/slices/tabs/utils' +import { cn } from '@root/frontend/utils/cn' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { + DEFAULT_VERIFY_TARGET, + parseVerifyTarget, + withVerifyTarget, +} from '@root/middleware/shared/utils/library/manifest-build-block' +import { useMemo, useState } from 'react' + +import { ResourcesTab } from './resources-tab' +import { VerifyTargetTab } from './verify-target-tab' + +type SettingsTab = 'verify' | 'resources' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +const BuildSettingsEditor = () => { + const [activeTab, setActiveTab] = useState('verify') + + const manifestContent = useOpenPLCStore((s) => s.project.data.libraryManifest ?? '') + const updateLibraryManifest = useOpenPLCStore((s) => s.projectActions.updateLibraryManifest) + const handleFileAndWorkspaceSavedState = useOpenPLCStore( + (s) => s.sharedWorkspaceActions.handleFileAndWorkspaceSavedState, + ) + const addFile = useOpenPLCStore((s) => s.fileActions.addFile) + + /** + * Derived from the manifest on every render rather than held in state, so + * the screen always shows what the Manifest tab holds right now — including + * a hand-edited `build` block, and including a half-typed one. + */ + const parsed = useMemo((): { target: LibraryVerifyTarget } | { error: string } => { + let raw: unknown + try { + raw = JSON.parse(manifestContent) + } catch { + return { error: 'library.json is not valid JSON. Fix it on the Manifest tab first.' } + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { error: 'library.json must be a JSON object.' } + } + const result = parseVerifyTarget(raw as Record) + if ('errors' in result) return { error: result.errors[0] } + return { target: result.target } + }, [manifestContent]) + + const manifestError = 'error' in parsed ? parsed.error : null + const target = 'target' in parsed ? parsed.target : DEFAULT_VERIFY_TARGET + + const handleTargetChange = (next: LibraryVerifyTarget) => { + const updated = withVerifyTarget(manifestContent, next) + if (updated === null) return + // Nothing is saved under this tab's own name: the setting lives in + // `library.json`, so the Manifest entry is what goes dirty. That entry is + // registered when the Manifest tab mounts, and this screen can be opened + // without ever opening it — `addFile` is a no-op when it exists. + addFile({ + name: LIBRARY_MANIFEST_TAB_NAME, + type: 'library-manifest', + filePath: 'library.json', + cleanState: manifestContent, + }) + handleFileAndWorkspaceSavedState(LIBRARY_MANIFEST_TAB_NAME) + updateLibraryManifest(updated) + } + + return ( +
+
+

Build Settings

+

+ Choose the toolchain this library is checked against, and manage the C/C++ libraries it ships. +

+
+ + setActiveTab(v as SettingsTab)} + className='flex min-h-0 flex-1 flex-col overflow-hidden' + > + + + + + + + + + + + + + +
+ ) +} + +export { BuildSettingsEditor } diff --git a/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx b/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx new file mode 100644 index 000000000..1d5b8d294 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx @@ -0,0 +1,266 @@ +/** + * Resources tab — the C/C++ library folders packaged into the `.stlib`. + * + * Dual-card layout, matching the Library Manager: the folders on the left, + * the selected folder's files on the right. These are files on disk, not + * project state, so add and remove take effect as they are made — the same + * way the Library Manager installs an archive. + */ + +import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' +import type { LibraryResourceFolder } from '@root/middleware/shared/ports/project-port' +import { useProject } from '@root/middleware/shared/providers' +import { useCallback, useEffect, useState } from 'react' + +import { PlusIcon } from '../../../../../assets/icons/interface/Plus' +import { TrashCanIcon } from '../../../../../assets/icons/interface/TrashCan' +import { useToast } from '../../../[app]/toast/use-toast' + +const ResourcesTab = () => { + const projectPort = useProject() + const { toast } = useToast() + const libraryName = useOpenPLCStore((s) => s.project.meta.name) + + const [folders, setFolders] = useState([]) + const [selected, setSelected] = useState(null) + /** Folder whose trash icon was clicked, awaiting confirmation. Removing one + * deletes its whole tree from disk with nothing to undo it. */ + const [pendingRemoval, setPendingRemoval] = useState(null) + const [isBusy, setIsBusy] = useState(false) + + // `in` rather than a truthiness check on the members: reading a method off + // the port without calling it is what the unbound-method rule flags. + const canManage = 'listLibraryResources' in projectPort && 'addLibraryResource' in projectPort + + const refresh = useCallback(async () => { + if (!projectPort.listLibraryResources) return + const result = await projectPort.listLibraryResources() + setPendingRemoval(null) + if (!result.success) { + toast({ title: 'Could not read resources', description: result.error, variant: 'fail' }) + return + } + const next = result.folders ?? [] + setFolders(next) + // Keep the selection only while it still names a folder. + setSelected((current) => (current && next.some((f) => f.name === current) ? current : (next[0]?.name ?? null))) + }, [projectPort, toast]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const handleAdd = async () => { + if (!projectPort.addLibraryResource) return + setIsBusy(true) + try { + const result = await projectPort.addLibraryResource() + // The user dismissed the picker — nothing went wrong, so say nothing. + if (result.canceled) return + if (!result.success) { + toast({ title: 'Could not add the folder', description: result.error, variant: 'fail' }) + return + } + await refresh() + if (result.folder) setSelected(result.folder.name) + toast({ title: `Added ${result.folder?.name ?? 'folder'}`, variant: 'default' }) + } finally { + setIsBusy(false) + } + } + + const handleRemove = async (name: string) => { + if (!projectPort.removeLibraryResource) return + setIsBusy(true) + try { + const result = await projectPort.removeLibraryResource(name) + if (!result.success) { + toast({ title: `Could not remove ${name}`, description: result.error, variant: 'fail' }) + return + } + await refresh() + } finally { + setIsBusy(false) + } + } + + const selectedFolder = folders.find((folder) => folder.name === selected) ?? null + + return ( +
+ void handleAdd()} + disabled={isBusy} + aria-label='Add library folder' + title='Add a library folder to resources' + className={cn( + 'shrink-0 rounded-md p-1 hover:bg-neutral-200 dark:hover:bg-neutral-800', + isBusy && 'cursor-not-allowed opacity-50', + )} + > + + + ) : null + } + > + + {folders.length === 0 ? ( + + Add a C/C++ library folder and it ships inside the .stlib, so a project that installs this library + compiles it for its own target. + + ) : ( + folders.map((folder) => ( +
+ + + {pendingRemoval === folder.name ? ( + + + + + ) : ( + + )} +
+ )) + )} +
+
+ + + + {!selectedFolder ? ( + Nothing selected. + ) : selectedFolder.files.length === 0 ? ( + This folder is empty. + ) : ( + selectedFolder.files.map((file) => ( +
+ {file} +
+ )) + )} +
+
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Subcomponents +// ───────────────────────────────────────────────────────────────────────────── + +function Card({ + title, + subtitle, + action, + children, +}: { + title: string + subtitle?: string + action?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+
+

+ {title} +

+ {subtitle &&

{subtitle}

} +
+ {action} +
+
{children}
+
+ ) +} + +/** Scrolling list body. Rows carry `shrink-0`: a flex column shrinks its + * children by default, so a long list collapses each row below its own + * height instead of scrolling. */ +function ListBody({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export { ResourcesTab } diff --git a/src/frontend/components/_features/[workspace]/editor/build-settings/verify-target-tab.tsx b/src/frontend/components/_features/[workspace]/editor/build-settings/verify-target-tab.tsx new file mode 100644 index 000000000..95ba95978 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/verify-target-tab.tsx @@ -0,0 +1,385 @@ +/** + * Verify Target tab — which toolchain the library is checked with. + * + * Dual-card transfer shape, matching the Library Manager's Project Libraries + * tab: the toolchain on the left, the core it compiles for on the right. A + * summary above both states the setting in a sentence, because the whole + * point of the screen is to show what `library.json` currently says. + */ + +import { openPackageManagerTab } from '@root/frontend/services/open-package-manager-tab' +import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { useCapabilities } from '@root/middleware/shared/providers' +import { + pickVerifyBoard, + SIMULATOR_BOARD, + SIMULATOR_CORE, +} from '@root/middleware/shared/utils/library/pick-verify-board' +import { useMemo, useState } from 'react' + +import { MagnifierIcon } from '../../../../../assets/icons/interface/Magnifier' + +type VerifyTargetTabProps = { + target: LibraryVerifyTarget + onChange: (target: LibraryVerifyTarget) => void + /** Set when `library.json` cannot be read; every control is disabled and + * the message replaces the summary. */ + manifestError: string | null +} + +type ModeOption = { + mode: LibraryVerifyTarget['mode'] + label: string + description: string +} + +/** + * Arduino is first and is the default. A library's blocks reach the Arduino + * core through `Arduino.h`, `Serial`, `WiFi` and the rest, and only this mode + * compiles them — the runtime builds its own upload, so nothing in the editor + * would compile the C++ for it. + */ +const MODE_OPTIONS: ModeOption[] = [ + { + mode: 'arduino', + label: 'Arduino core', + description: 'Compiles the blocks and the resources with the Arduino toolchain.', + }, + { + mode: 'runtime', + label: 'OpenPLC Runtime', + description: 'Checks the Structured Text and the runtime bundle. The runtime compiles the C++ itself.', + }, + { + mode: 'off', + label: 'Do not verify', + description: 'Build the .stlib without checking it.', + }, +] + +const VerifyTargetTab = ({ target, onChange, manifestError }: VerifyTargetTabProps) => { + const availableBoards = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards) + const hasPackageManager = useCapabilities().hasPackageManager + const [coreFilter, setCoreFilter] = useState('') + + /** + * Installed cores, grouped by the vendor whose package supplies them, with + * the boards carrying each. Runtime targets declare no core and are covered + * by the OpenPLC Runtime mode instead. + */ + const groupedCores = useMemo(() => { + const BUILT_IN_VENDOR = 'OpenPLC' + const byVendor = new Map>() + for (const [, data] of availableBoards.entries()) { + if (!data.core || data.compiler === 'openplc-compiler') continue + const vendor = data.vpp?.vendor ?? BUILT_IN_VENDOR + const cores = byVendor.get(vendor) ?? new Map() + cores.set(data.core, (cores.get(data.core) ?? 0) + 1) + byVendor.set(vendor, cores) + } + + const builtIn = byVendor.get(BUILT_IN_VENDOR) + byVendor.delete(BUILT_IN_VENDOR) + const toList = (cores: Map) => + [...cores.entries()].map(([core, boards]) => ({ core, boards })).sort((a, b) => a.core.localeCompare(b.core)) + const ordered: Array<{ vendor: string; cores: Array<{ core: string; boards: number }> }> = [] + if (builtIn) ordered.push({ vendor: BUILT_IN_VENDOR, cores: toList(builtIn) }) + for (const vendor of [...byVendor.keys()].sort((a, b) => a.localeCompare(b))) { + ordered.push({ vendor, cores: toList(byVendor.get(vendor) as Map) }) + } + return ordered + }, [availableBoards]) + + const filteredCores = useMemo(() => { + const needle = coreFilter.trim().toLowerCase() + if (!needle) return groupedCores + return groupedCores + .map(({ vendor, cores }) => ({ + vendor, + cores: vendor.toLowerCase().includes(needle) + ? cores + : cores.filter(({ core }) => core.toLowerCase().includes(needle)), + })) + .filter(({ cores }) => cores.length > 0) + }, [groupedCores, coreFilter]) + + /** The core recorded in the manifest is not necessarily installed. Say so + * rather than dropping it — the build warns and falls back, it does not + * rewrite the manifest. */ + const coreIsInstalled = useMemo( + () => (target.core ? groupedCores.some(({ cores }) => cores.some(({ core }) => core === target.core)) : true), + [groupedCores, target.core], + ) + + /** The board that will stand in for the chosen core, by the same rule the + * build uses. Named here so the choice is not first seen in a build log. */ + const standInBoard = useMemo(() => { + if (target.mode !== 'arduino') return null + if (!target.core) return SIMULATOR_BOARD + return ( + pickVerifyBoard( + [...availableBoards.entries()].map(([name, info]) => ({ name, core: info.core, compiler: info.compiler })), + target.core, + ) ?? SIMULATOR_BOARD + ) + }, [availableBoards, target.core, target.mode]) + + const isArduino = target.mode === 'arduino' + /** The core only matters to the Arduino toolchain, so the whole card is off + * under the other two modes — and under a manifest we could not read. */ + const coreCardDisabled = Boolean(manifestError) || !isArduino + + return ( +
+ + +
+ + + {MODE_OPTIONS.map((option) => ( + onChange({ ...target, mode: option.mode })} + /> + ))} + + + + +
+ + setCoreFilter(e.target.value)} + disabled={coreCardDisabled} + placeholder='Search cores…' + aria-label='Search cores' + className='w-full bg-transparent font-caption text-xs text-neutral-950 placeholder:text-neutral-400 focus:outline-none disabled:cursor-not-allowed dark:text-white dark:placeholder:text-neutral-500' + /> +
+ + + {/* The simulator is what an Arduino target with no core resolves + to, so it is offered as a row rather than left implicit. */} + onChange({ mode: target.mode })} + /> + + {!coreIsInstalled && target.core && ( + undefined} + /> + )} + + {filteredCores.length === 0 ? ( + {coreFilter ? `No cores match “${coreFilter}”.` : 'No cores installed.'} + ) : ( + filteredCores.map(({ vendor, cores }) => ( +
+
+ {vendor} +
+ {cores.map(({ core, boards }) => ( + onChange({ ...target, core })} + /> + ))} +
+ )) + )} +
+ + {hasPackageManager && ( + + )} +
+
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Subcomponents +// ───────────────────────────────────────────────────────────────────────────── + +/** What `library.json` currently says, in a sentence. */ +function Summary({ + manifestError, + target, + standInBoard, +}: { + manifestError: string | null + target: LibraryVerifyTarget + standInBoard: string | null +}) { + if (manifestError) { + return ( +
+ {manifestError} +
+ ) + } + + const sentence = + target.mode === 'off' + ? 'This library is not verified. The .stlib is built without checking it.' + : target.mode === 'runtime' + ? 'Verified as an OpenPLC Runtime bundle. The Structured Text and the bundle are checked; the runtime compiles the C++ itself.' + : `Verified with the Arduino toolchain for ${target.core ?? SIMULATOR_CORE}, compiling on ${standInBoard ?? SIMULATOR_BOARD}.` + + return ( +
+

{sentence}

+

+ Stored in library.json. Save the project to keep it. +

+
+ ) +} + +/** A card whose whole surface dims when the setting it holds does not apply — + * heading and search included, so a live-looking control never sits inside a + * section that is off. */ +function Card({ + title, + subtitle, + disabled, + children, +}: { + title: string + subtitle?: string + disabled?: boolean + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ) +} + +/** Scrolling list body. Rows carry `shrink-0`: a flex column shrinks its + * children by default, so a long list collapses each row below its own + * height instead of scrolling. */ +function ListBody({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +/** + * One selectable row. The radio mark carries the selection rather than a + * background tint alone — a tint at the contrast the rest of the editor uses + * is not readable enough to answer "which one is set?" at a glance. + * + * A disabled row does not dim itself: its card is already dimmed whenever its + * rows are disabled, and nested opacity multiplies, which would leave the row + * at a fifth of full contrast rather than half. + */ +function OptionRow({ + label, + description, + selected, + disabled, + onSelect, +}: { + label: string + description: string + selected: boolean + disabled: boolean + onSelect: () => void +}) { + return ( + + ) +} + +export { VerifyTargetTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 1b17f0b7e..5b1621a80 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -11,6 +11,7 @@ import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' import { useDeviceLicense } from '../../../../../../hooks/use-device-license' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' +import { openPackageManagerTab } from '../../../../../../services/open-package-manager-tab' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' @@ -286,21 +287,7 @@ const Board = memo(function () { const handleSetDeviceBoard = useCallback( (board: string) => { if (board === '__install_additional_boards__') { - const { tabsActions, editorActions } = useOpenPLCStore.getState() - const tab = { - name: 'Package Manager', - path: '/package-manager', - elementType: { type: 'package-manager' as const }, - } - tabsActions.updateTabs(tab) - const existing = editorActions.getEditorFromEditors(tab.name) - if (!existing) { - const model = { type: 'plc-package-manager' as const, meta: { name: 'Package Manager' } } - editorActions.addModel(model) - editorActions.setEditor(model) - } else { - editorActions.setEditor(existing) - } + openPackageManagerTab() return } diff --git a/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx b/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx index 40b1d01e0..698cad340 100644 --- a/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx @@ -179,6 +179,9 @@ function SearchBar({ value, onChange }: { value: string; onChange: (v: string) = ) } +/** Scrolling list body. Rows carry `shrink-0`: a flex column shrinks its + * children by default, so a long list collapses each row below its own + * height instead of scrolling. */ function ListBody({ children }: { children: React.ReactNode }) { return
{children}
} @@ -203,7 +206,7 @@ function LibraryRow({ actionTitle?: string }) { return ( -
+
{lib.displayName ?? lib.name} diff --git a/src/frontend/components/_molecules/breadcrumbs/index.tsx b/src/frontend/components/_molecules/breadcrumbs/index.tsx index f2c6492aa..ae86cb2e1 100644 --- a/src/frontend/components/_molecules/breadcrumbs/index.tsx +++ b/src/frontend/components/_molecules/breadcrumbs/index.tsx @@ -156,6 +156,20 @@ const Breadcrumbs = () => { ) } + // Build Settings — sits beside the manifest, and is stored in it. + if (editor.type === 'plc-build-settings') { + return ( +
    +
  1. + +
  2. +
  3. + +
  4. +
+ ) + } + // EtherCAT slave device breadcrumbs if (editor.type === 'plc-ethercat-device') { return ( diff --git a/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx b/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx index 164a55e7d..16622ff20 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx @@ -8,8 +8,10 @@ import type { PLCStructureVariable } from '../../../../../../middleware/shared/p import { ArrowIcon } from '../../../../../assets/icons/interface/Arrow' import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' +import { isLengthQualifiedType } from '../../../../../utils/iec-types-registry' import { hasStringName, safeUpper } from '../../../../../utils/safe-upper' import { InputWithRef } from '../../../../_atoms/input' +import { seedStringLengths, StringLengthMenuItem } from '../../../../_atoms/string-length-menu-item' import { ArrayModal } from './elements/array-modal' type ISelectableCellProps = CellContext & { editable?: boolean } @@ -72,6 +74,7 @@ const SelectableTypeCell = ({ 'user-data-type': '', }) const [inputFilter, setInputFilter] = useState('') + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const variableName = table.options.data[index].name @@ -119,7 +122,7 @@ const SelectableTypeCell = ({ ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -163,17 +166,34 @@ const SelectableTypeCell = ({ />
{scope.values.length > 0 ? ( - scope.values.map((value) => ( - - onSelect(scope.definition as PLCStructureVariable['type']['definition'], value) - } - className='flex h-8 items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - {_.upperCase(value)} - - )) + scope.values.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCStructureVariable['type']['definition'], declaredType) + } + /> + ) : ( + + onSelect(scope.definition as PLCStructureVariable['type']['definition'], value) + } + className='flex h-8 items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx b/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx index fc9458d54..6207c3141 100644 --- a/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx @@ -10,10 +10,12 @@ import { DebuggerIcon } from '../../../assets/icons/interface/Debugger' import { useOpenPLCStore } from '../../../store' import { TypeChangeValidationResult, validateTypeChange } from '../../../store/slices/project/validation/type-change' import { cn } from '../../../utils/cn' +import { isLengthQualifiedType } from '../../../utils/iec-types-registry' import { hasStringName, safeUpper } from '../../../utils/safe-upper' import { propagateVariableTypeChange } from '../../../utils/variable-references' import { InputWithRef } from '../../_atoms/input' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../_atoms/select' +import { seedStringLengths, StringLengthMenuItem } from '../../_atoms/string-length-menu-item' import { TypeChangeModal } from '../type-change-modal' import { GlobalArrayModal } from './elements/array-modal' @@ -101,6 +103,7 @@ const SelectableTypeCell = ({ value: PLCVariable['type']['value'] } | null>(null) const [validationResult, setValidationResult] = useState(null) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const [variableFilters, setVariableFilters] = useState>({ 'base-type': '', @@ -221,11 +224,13 @@ const SelectableTypeCell = ({ })} > + {/* `toUpperCase`, not lodash `upperCase`: the latter splits on + punctuation, rendering STRING(15) as "STRING 15". */} {cellValue === null ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -272,19 +277,33 @@ const SelectableTypeCell = ({ />
{filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - - onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value) - } - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCGlobalVariable['type']['definition'], declaredType) + } + /> + ) : ( + + onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value) + } + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 47e42d8eb..a9b7ed7dd 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -469,6 +469,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'ethercatDevice' | 'softMotionDrive' | 'libraryManifest' + | 'buildSettings' | 'userManagement' leafType: WorkspaceProjectTreeLeafType label?: string @@ -510,6 +511,9 @@ const LeafSources = { // render the same glyph — the manifest is the user's entry point // into a library project, so it earns a dedicated mark. libraryManifest: { LeafIcon: LibraryManifestIcon }, + // Build Settings shares the device-configuration gear: both are the + // settings screen for how the project is built. + buildSettings: { LeafIcon: ConfigIcon }, userManagement: { LeafIcon: UsersIcon }, } const ProjectTreeLeaf = ({ @@ -864,7 +868,10 @@ const ProjectTreeLeaf = ({ )} - {leafLang === 'devPin' || leafLang === 'devConfig' || leafLang === 'userManagement' ? null : ( + {leafLang === 'devPin' || + leafLang === 'devConfig' || + leafLang === 'buildSettings' || + leafLang === 'userManagement' ? null : ( (null) const [validationResult, setValidationResult] = useState(null) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const variableName = table.options.data[index].name const currentVariable = table.options.data[index] @@ -289,11 +292,13 @@ const SelectableTypeCell = ({ })} > + {/* `toUpperCase`, not lodash `upperCase`: the latter splits on + punctuation, rendering STRING(15) as "STRING 15". */} {cellValue === null ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -340,17 +345,31 @@ const SelectableTypeCell = ({ />
{filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - onSelect(scope.definition as PLCVariable['type']['definition'], value)} - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCVariable['type']['definition'], declaredType) + } + /> + ) : ( + onSelect(scope.definition as PLCVariable['type']['definition'], value)} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index 71e842d77..a46dead02 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -6,7 +6,11 @@ import { FolderIcon } from '../../../assets/icons/interface/Folder' import { useTargetCapabilities } from '../../../hooks/use-target-capabilities' import { useOpenPLCStore } from '../../../store' import type { TabsProps } from '../../../store/slices/tabs' -import { CreateEditorObjectFromTab, LIBRARY_MANIFEST_TAB_NAME } from '../../../store/slices/tabs/utils' +import { + BUILD_SETTINGS_TAB_NAME, + CreateEditorObjectFromTab, + LIBRARY_MANIFEST_TAB_NAME, +} from '../../../store/slices/tabs/utils' import { isUserManagementCapableRuntime } from '../../../utils/device' import { useToast } from '../../_features/[app]/toast/use-toast' import { CreatePLCElement } from '../../_features/[workspace]/create-element' @@ -196,18 +200,35 @@ const Project = () => { is mandatory for `.stlib` builds). Only rendered for library projects. */} {projectCaps.hasLibraryManifest && ( - - handleCreateTab({ - name: LIBRARY_MANIFEST_TAB_NAME, - path: '/library.json', - elementType: { type: 'library-manifest' }, - }) - } - /> + <> + + handleCreateTab({ + name: LIBRARY_MANIFEST_TAB_NAME, + path: '/library.json', + elementType: { type: 'library-manifest' }, + }) + } + /> + {/* Build Settings sits under the manifest because that is where + it is stored: the verify target is a `build` block in + `library.json`. */} + + handleCreateTab({ + name: BUILD_SETTINGS_TAB_NAME, + path: '/build-settings', + elementType: { type: 'build-settings' }, + }) + } + /> + )} {/* Project Functions tree branch */} diff --git a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx index 3b74ebf1f..991b4edc0 100644 --- a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx +++ b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx @@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from 'react' import { baseTypeEnum } from '../../../../middleware/shared/ports/plc-schemas' import type { VariableClass } from '../../../../middleware/shared/ports/types' import type { CreateGraphicalVariableModalData } from '../../../store/slices/modal/types' +import { cn } from '../../../utils/cn' +import { isLengthQualifiedType, MAX_STRING_LENGTH, parseStringLength } from '../../../utils/iec-types-registry' import { getVariableRestrictionType } from '../../../utils/PLC/validate-variable-type' import { Label } from '../../_atoms/label' import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' @@ -39,6 +41,8 @@ const CreateGraphicalVariableModal = ({ const [name, setName] = useState(data.name) const [variableClass, setVariableClass] = useState('local') const [typeValue, setTypeValue] = useState(data.suggestedType.value) + // Empty means the unqualified type. + const [stringLength, setStringLength] = useState('') // A reused instance must never carry the previous pin's answers over. useEffect(() => { @@ -71,16 +75,20 @@ const CreateGraphicalVariableModal = ({ onClose() } + const lengthIsOffered = isLengthQualifiedType(typeValue) + const declaredType = lengthIsOffered && stringLength.trim() !== '' ? `${typeValue}(${stringLength.trim()})` : typeValue + const lengthIsValid = !lengthIsOffered || stringLength.trim() === '' || parseStringLength(declaredType).valid + const handleConfirm = () => { const trimmedName = name.trim() - if (!trimmedName) return + if (!trimmedName || !lengthIsValid) return const selected = typeOptions.find((option) => option.value === typeValue) data.onConfirm({ name: trimmedName, class: variableClass, // An option the list doesn't know can only come from the suggestion, so // keep the definition the editor derived for it. - type: selected ? { definition: selected.definition, value: selected.value } : data.suggestedType, + type: selected ? { definition: selected.definition, value: declaredType } : data.suggestedType, }) onClose() } @@ -154,6 +162,23 @@ const CreateGraphicalVariableModal = ({ ))}
+ + {lengthIsOffered && ( +
+ + setStringLength(event.target.value)} + className={cn(inputClass, !lengthIsValid && 'border-red-500 text-red-500')} + /> +
+ )}
diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index aa121ed31..b273fb03e 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -16,6 +16,7 @@ import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' import { BranchStatusBar } from '../components/_features/[workspace]/branches' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' +import { BuildSettingsEditor } from '../components/_features/[workspace]/editor/build-settings' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' import { EtherCATDeviceEditor, EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' @@ -57,6 +58,7 @@ import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monit import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' +import { openPackageManagerTab } from '../services/open-package-manager-tab' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' import { buildGlobalCompositeKey, GLOBAL_CONFIG_NAME } from '../utils/debug-variable-finder' @@ -410,23 +412,7 @@ const WorkspaceScreen = () => { useEffect(() => { if (!packagesPort) return - const unsubOpen = packagesPort.onOpenManager(() => { - const { tabsActions, editorActions } = useOpenPLCStore.getState() - const tab = { - name: 'Package Manager', - path: '/package-manager', - elementType: { type: 'package-manager' as const }, - } - tabsActions.updateTabs(tab) - const existing = editorActions.getEditorFromEditors(tab.name) - if (!existing) { - const model = { type: 'plc-package-manager' as const, meta: { name: 'Package Manager' } } - editorActions.addModel(model) - editorActions.setEditor(model) - } else { - editorActions.setEditor(existing) - } - }) + const unsubOpen = packagesPort.onOpenManager(() => openPackageManagerTab()) const unsubBoards = packagesPort.onBoardsUpdated(() => { void device.getAvailableBoards().then((boardsMap) => { @@ -589,6 +575,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-library-manager' && } {editor['type'] === 'plc-user-management' && } {editor['type'] === 'plc-library-manifest' && } + {editor['type'] === 'plc-build-settings' && } {editor['type'] === 'diff-viewer' && } {/* EtherCAT device editors — multi-instance (one tab diff --git a/src/frontend/services/open-package-manager-tab.ts b/src/frontend/services/open-package-manager-tab.ts new file mode 100644 index 000000000..d189f23e7 --- /dev/null +++ b/src/frontend/services/open-package-manager-tab.ts @@ -0,0 +1,29 @@ +import { useOpenPLCStore } from '../store' + +/** Tab name, path and model the Package Manager screen is registered under. */ +const PACKAGE_MANAGER_TAB_NAME = 'Package Manager' + +/** + * Open the Package Manager, or focus it when it is already open. + * + * Reached from three places — the device board dropdown, the library Build + * Settings core dropdown, and the main-process "open manager" event — so the + * tab/model registration lives here rather than being repeated at each. + */ +export function openPackageManagerTab(): void { + const { tabsActions, editorActions } = useOpenPLCStore.getState() + const tab = { + name: PACKAGE_MANAGER_TAB_NAME, + path: '/package-manager', + elementType: { type: 'package-manager' as const }, + } + tabsActions.updateTabs(tab) + const existing = editorActions.getEditorFromEditors(tab.name) + if (existing) { + editorActions.setEditor(existing) + return + } + const model = { type: 'plc-package-manager' as const, meta: { name: PACKAGE_MANAGER_TAB_NAME } } + editorActions.addModel(model) + editorActions.setEditor(model) +} diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 2c5bc5511..e03f24ae3 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -219,6 +219,14 @@ export type EditorModel = EditorModelBase & name: string } } + | { + /** Build Settings for a Library Project — the verify target and + * the `resources/` folders. Library projects only. */ + type: 'plc-build-settings' + meta: { + name: string + } + } | { type: 'plc-ethercat-device' meta: { diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index b3f620942..b27f8ed16 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -19,6 +19,7 @@ export type TabsProps = { | { type: 'package-manager' } | { type: 'library-manager' } | { type: 'library-manifest' } + | { type: 'build-settings' } | { type: 'user-management' } | { type: 'ethercat-device'; busName: string; deviceId: string } | { type: 'diff-viewer'; filePath: string } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index bf5b8f7b0..31efbc7ae 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -144,6 +144,16 @@ const CreateLibraryManifestEditor = (name = LIBRARY_MANIFEST_TAB_NAME): EditorMo meta: { name }, }) +/** Canonical tab name + factory for the Library Project's Build Settings. + * Nothing is saved under this name — the verify target lives in + * `library.json`, so the Manifest file entry carries the dirty flag. */ +const BUILD_SETTINGS_TAB_NAME = 'Build Settings' + +const CreateBuildSettingsEditor = (name = BUILD_SETTINGS_TAB_NAME): EditorModel => ({ + type: 'plc-build-settings', + meta: { name }, +}) + /** Read-only source-control diff tab. The tab `name` doubles as the unique * editor key, so it must not collide with the editable POU tab of the same * POU — callers pass a `Diff: ` style name. `filePath` is the @@ -203,6 +213,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateLibraryManagerEditor(name) case 'library-manifest': return CreateLibraryManifestEditor(name) + case 'build-settings': + return CreateBuildSettingsEditor(name) case 'user-management': return CreateUserManagementEditor(name) case 'diff-viewer': @@ -211,6 +223,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { } export { + BUILD_SETTINGS_TAB_NAME, + CreateBuildSettingsEditor, CreateDeviceEditor, CreateDiffViewerEditor, CreateEditorModelObject, diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 6b367affb..73544a626 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -39,6 +39,7 @@ export type WorkspaceProjectTreeLeafType = | 'package-manager' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'ethercat-device' | null diff --git a/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts b/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts index 89b04f4df..2e6e9cbdb 100644 --- a/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts +++ b/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts @@ -7,6 +7,7 @@ import { getArrayTotalElements, getVariableIECType, isArrayVariable, + isVariableLengthArray, mapBaseTypeToIEC, mapUserTypeToIEC, multiDimensionalContainerType, @@ -298,6 +299,57 @@ describe('the two native languages accept the same elementary types', () => { expect(generateStructMember(scalarOf(type))).toMatch(/^ {2}strucpp::IEC_\w+ \*V;\n$/) }) + // A declared length has to name the template directly: `IEC_STRING` is a fixed + // alias for `IECStringVar<254>`. The spelling must match what STruC++ emitted + // for the same declaration, because `_VARS` holds a pointer to the very + // member the function block declares — a disagreement here is an ABI mismatch, + // not a compile error. + describe('a declared string length on a native block pin', () => { + it('names the template rather than the 254-character alias', () => { + expect(mapBaseTypeToIEC('STRING(23)')).toBe('IECStringVar<23>') + expect(mapBaseTypeToIEC('WSTRING(8)')).toBe('IECWStringVar<8>') + }) + + it('reaches the struct member', () => { + expect(generateStructMember(scalarOf('STRING(23)'))).toBe(' strucpp::IECStringVar<23> *V;\n') + }) + + it('leaves an unqualified string on the alias', () => { + expect(mapBaseTypeToIEC('string')).toBe('IEC_STRING') + expect(generateStructMember(scalarOf('STRING'))).toBe(' strucpp::IEC_STRING *V;\n') + }) + + it('reads the bracket form too, since the parser normalises either way', () => { + expect(mapBaseTypeToIEC('STRING[23]')).toBe('IECStringVar<23>') + }) + + it('does not invent a template for a length nothing can carry', () => { + // Falls through to the ordinary spelling rule rather than emitting + // `IECStringVar<0>`, which would not compile. + expect(mapBaseTypeToIEC('STRING(0)')).not.toContain('IECStringVar<') + expect(mapBaseTypeToIEC('INT(4)')).not.toContain('IECStringVar<') + }) + + it('carries the element length of an array of strings', () => { + const arrayOfSized: PLCVariable = { + name: 'v', + class: 'input', + type: { + definition: 'array', + value: 'ARRAY[0..3] OF STRING(23)', + data: { + baseType: { definition: 'base-type', value: 'STRING(23)' }, + dimensions: [{ dimension: '0..3' }], + }, + }, + location: '', + documentation: '', + debug: false, + } + expect(getVariableIECType(arrayOfSized)).toBe('IECStringVar<23>') + }) + }) + it('accepts the long spellings IEC 61131-3 allows for the calendar types', () => { expect(mapBaseTypeToIEC('time_of_day')).toBe('IEC_TOD') expect(mapBaseTypeToIEC('date_and_time')).toBe('IEC_DT') @@ -329,6 +381,117 @@ describe('mapUserTypeToIEC', () => { }) }) +describe('generic types on a native block pin', () => { + // A native block may declare a VAR_INPUT with one of CODESYS's seven generic + // types. Every one is the same `IEC_ANY` descriptor at the ABI: the family + // constrains what the caller may pass, which the compiler checks at the call + // site, not what the block receives. + const genericPin = (typeName: string): PLCVariable => ({ + name: 'p', + class: 'input', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + debug: false, + }) + + it.each(['ANY', 'ANY_BIT', 'ANY_DATE', 'ANY_NUM', 'ANY_REAL', 'ANY_INT', 'ANY_STRING'])( + 'spells %s as the IEC_ANY descriptor', + (generic) => { + expect(mapUserTypeToIEC(generic)).toBe('IEC_ANY') + expect(generateStructMember(genericPin(generic))).toBe(' strucpp::IEC_ANY *P;\n') + }, + ) + + it('matches case-insensitively, as every other type spelling does', () => { + expect(mapUserTypeToIEC('any_int')).toBe('IEC_ANY') + }) + + it('leaves a user type called ANYTHING alone — only the exact names are generic', () => { + expect(mapUserTypeToIEC('ANYTHING', new Set(['ANYTHING']))).toBe('IEC_ANYTHING') + }) + + it('spells __SYSTEM.AnyType as the same descriptor', () => { + // The concrete structure a generic parameter carries. A native block may + // declare one to keep what it was passed. + expect(mapUserTypeToIEC('__SYSTEM.AnyType')).toBe('IEC_ANY') + expect(generateStructMember(genericPin('__SYSTEM.AnyType'))).toBe(' strucpp::IEC_ANY *P;\n') + }) +}) + +describe('variable-length arrays', () => { + // `ARRAY [*]` is legal as a function block's in-out variable. strucpp passes + // it as an ArrayView carrying the runtime bounds, so the struct holds a + // pointer to the view: there is no lower bound yet to offset by, and an + // element pointer would drop the only record of the length. + const vlaOfRank = ( + dimensions: string[], + baseType = 'INT', + baseDefinition: 'base-type' | 'user-data-type' = 'base-type', + ): PLCVariable => ({ + name: 'values', + class: 'inOut', + type: { + definition: 'array', + value: `ARRAY [${dimensions.join(', ')}] OF ${baseType}`, + data: { + baseType: { definition: baseDefinition, value: baseType }, + dimensions: dimensions.map((dimension) => ({ dimension })), + }, + }, + location: '', + documentation: '', + debug: false, + }) + + it('passes a 1-D VLA as a pointer to the view, not to the first element', () => { + expect(generateStructMember(vlaOfRank(['*']))).toBe(' strucpp::ArrayView1D *VALUES;\n') + }) + + it('passes a 2-D VLA as a view of its own rank', () => { + expect(generateStructMember(vlaOfRank(['*', '*'], 'REAL'))).toBe( + ' strucpp::ArrayView2D *VALUES;\n', + ) + }) + + it('spells a user-defined element type the way strucpp declares it', () => { + const named = new Set(['MOTOR']) + expect(generateStructMember(vlaOfRank(['*'], 'MOTOR', 'user-data-type'), named)).toBe( + ' strucpp::ArrayView1D *VALUES;\n', + ) + }) + + it('tolerates whitespace around the bound', () => { + expect(isVariableLengthArray(vlaOfRank([' * ']))).toBe(true) + }) + + it('leaves a fixed array on the element-pointer path', () => { + expect(isVariableLengthArray(vlaOfRank(['0..9']))).toBe(false) + expect(generateStructMember(vlaOfRank(['0..9']))).toBe(' strucpp::IEC_INT *VALUES;\n') + }) + + it('does not treat a partly-variable shape as a VLA, since IEC allows no such array', () => { + expect(isVariableLengthArray(vlaOfRank(['*', '0..3']))).toBe(false) + }) + + it('returns null past rank two, for which strucpp declares no ArrayView', () => { + expect(isVariableLengthArray(vlaOfRank(['*', '*', '*']))).toBe(false) + }) + + it('returns false for anything that is not an array', () => { + const scalar: PLCVariable = { + name: 'x', + class: 'input', + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + debug: false, + } + + expect(isVariableLengthArray(scalar)).toBe(false) + }) +}) + describe('multiDimensionalContainerType', () => { const arrayOfRank = ( dimensions: string[], diff --git a/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts b/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts new file mode 100644 index 000000000..47f11c333 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts @@ -0,0 +1,104 @@ +import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { baseTypeSchema } from '../../../../middleware/shared/ports/plc-schemas' +import { canonicalGenericType, isGenericType, PLCOPEN_GENERIC_TYPES } from '../generic-types' +import { generateStructMember } from '../array-codegen-helpers' +import { convertTypeToXml } from '../xml-generator/old-editor/type-xml' +import { parseTypeXml } from '../xml-parser/type-xml' + +/** + * Generic types across the PLCopen XML boundary. + * + * PLCopen TC6 v2.01 lists all ten in the `elementaryTypes` group, so each has + * an element of its own — ``, not ``, which would + * name a user-defined type that happens to be called ANY. + * + * Internally they are `user-data-type`: `base-type` values are validated + * against the elementary registry, and a generic is deliberately not in it. + */ +describe('generic types over PLCopen XML', () => { + it('recognises exactly the ten PLCopen names', () => { + expect([...PLCOPEN_GENERIC_TYPES]).toEqual([ + 'ANY', + 'ANY_DERIVED', + 'ANY_ELEMENTARY', + 'ANY_MAGNITUDE', + 'ANY_NUM', + 'ANY_REAL', + 'ANY_INT', + 'ANY_BIT', + 'ANY_STRING', + 'ANY_DATE', + ]) + }) + + it('does not mistake a user type whose name merely starts with ANY', () => { + expect(isGenericType('ANYTHING')).toBe(false) + expect(canonicalGenericType('ANYTHING')).toBeNull() + }) + + it('compares case-insensitively, as IEC identifiers do', () => { + expect(canonicalGenericType('any_int')).toBe('ANY_INT') + expect(canonicalGenericType(' Any ')).toBe('ANY') + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('reads <%s/> as a named type, not a base type', (generic) => { + // `base-type` would be a lie the project schema then rejects — see below. + expect(parseTypeXml({ [generic]: '' })).toEqual({ + definition: 'user-data-type', + value: generic, + }) + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('writes %s back as its own element', (generic) => { + expect(convertTypeToXml({ definition: 'user-data-type', value: generic })).toEqual({ + [generic]: '', + }) + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('round-trips %s unchanged', (generic) => { + const parsed = parseTypeXml({ [generic]: '' }) + expect(convertTypeToXml(parsed)).toEqual({ [generic]: '' }) + }) + + it('is why a generic is not modelled as a base type', () => { + // The project's own schema validates `base-type` values against the + // elementary registry. Had the parser called a generic a base type, a + // project that merely mentions ANY would fail to save. + expect(baseTypeSchema.safeParse('ANY').success).toBe(false) + expect(baseTypeSchema.safeParse('INT').success).toBe(true) + }) + + it('still writes a real user-defined type as a derived reference', () => { + expect(convertTypeToXml({ definition: 'user-data-type', value: 'MOTOR' })).toEqual({ + derived: { '@name': 'MOTOR' }, + }) + }) + + it('still reads a derived reference as a user type', () => { + expect(parseTypeXml({ derived: { '@name': 'MOTOR' } })).toEqual({ + definition: 'derived', + value: 'MOTOR', + }) + }) + + it('leaves ordinary base types alone in both directions', () => { + expect(parseTypeXml({ INT: '' })).toEqual({ definition: 'base-type', value: 'INT' }) + expect(convertTypeToXml({ definition: 'base-type', value: 'INT' })).toEqual({ INT: '' }) + }) + + it('reaches the native bridge as the IEC_ANY descriptor', () => { + // The whole point of preserving the name: a native block declaring + // `P : ANY` must still get a descriptor pin after a save/load cycle. + const imported = parseTypeXml({ ANY: '' }) + const variable: PLCVariable = { + name: 'p', + class: 'input', + type: imported, + location: '', + documentation: '', + debug: false, + } + + expect(generateStructMember(variable)).toBe(' strucpp::IEC_ANY *P;\n') + }) +}) diff --git a/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts index 4405052c0..59b778b62 100644 --- a/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts +++ b/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts @@ -22,12 +22,34 @@ describe('findLastEndVarIndex', () => { expect(findLastEndVarIndex(content, 0)).toBe(-1) }) - it('starts searching from the given start index', () => { - const content = 'END_VAR first END_VAR second' - // Search starting from after the first END_VAR - const startAfterFirst = 'END_VAR'.length - const idx = findLastEndVarIndex(content, startAfterFirst) - expect(idx).toBe(content.lastIndexOf('END_VAR') + 'END_VAR'.length) + it('starts at the given index, ignoring anything before it', () => { + const content = 'PROGRAM p VAR a : INT; END_VAR VAR b : BOOL; END_VAR body' + const secondSection = content.indexOf('VAR b') + expect(findLastEndVarIndex(content, secondSection)).toBe(content.lastIndexOf('END_VAR') + 'END_VAR'.length) + }) + + it('stops at the end of the declaration, not at an END_VAR in the body', () => { + // A graphical POU's body is JSON, and a block node in it can carry a + // native function block's source — `END_VAR` and all. Scanning to the last + // one in the file splits inside that JSON string, and a file the editor + // wrote a moment earlier stops parsing. + const content = [ + 'VAR', + ' FB0 : lib__BLOCK;', + 'END_VAR', + '', + '{"rungs":[{"body":"FUNCTION_BLOCK B\\n VAR_INPUT\\n X : INT;\\n END_VAR\\nvoid loop(){}\\nEND_FUNCTION_BLOCK"}]}', + ].join('\n') + + const idx = findLastEndVarIndex(content, content.indexOf('VAR')) + // The declaration's own END_VAR — the one before the JSON starts. + expect(idx).toBe(content.indexOf('END_VAR') + 'END_VAR'.length) + expect(content.slice(idx).trim().startsWith('{')).toBe(true) + }) + + it('consumes every consecutive VAR section', () => { + const content = 'VAR_INPUT a : INT; END_VAR\nVAR_OUTPUT b : BOOL; END_VAR\nbody' + expect(findLastEndVarIndex(content, 0)).toBe(content.lastIndexOf('END_VAR') + 'END_VAR'.length) }) it('is case-insensitive', () => { @@ -91,6 +113,26 @@ END_PROGRAM` expect(result.documentation).toBe('This is documented') }) + it('parses a header written as several consecutive comment blocks', () => { + const content = `(* First paragraph *) + +(* Second paragraph *) + +PROGRAM Main +VAR + x : INT; +END_VAR + +x := 1; + +END_PROGRAM` + + const result = parseTextualPouFromString(content, 'st', 'program') + expect(result.name).toBe('Main') + expect(result.documentation).toBe('First paragraph\n\nSecond paragraph') + expect(result.interface?.variables.length).toBe(1) + }) + it('parses a function with return type', () => { const content = `FUNCTION MyFunc : INT VAR_INPUT diff --git a/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts new file mode 100644 index 000000000..5a87612d8 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts @@ -0,0 +1,83 @@ +import { parseTypeXml } from '../xml-parser/type-xml' +import { convertTypeToXml } from '../xml-generator/old-editor/type-xml' + +/** + * A declared string length across the PLCopen XML boundary. + * + * TC6 carries the length on the element as an attribute — + * ``. + * + * The round trip is what these tests are for: a dropped length goes unnoticed, + * since the project still opens and still compiles as the 254-character + * default, at 518 bytes per variable instead of 54. + */ +describe('a declared string length over PLCopen XML', () => { + it('writes the length as a TC6 attribute', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'STRING(23)' })).toEqual({ + string: { '@length': '23' }, + }) + }) + + it('writes WSTRING the same way', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'WSTRING(8)' })).toEqual({ + wstring: { '@length': '8' }, + }) + }) + + it('leaves an unqualified string as a bare element', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'STRING' })).toEqual({ string: '' }) + }) + + it('reads the attribute back into the declaration', () => { + expect(parseTypeXml({ string: { '@length': '23' } })).toEqual({ + definition: 'base-type', + value: 'STRING(23)', + }) + }) + + it.each([ + ['STRING(1)'], + ['STRING(23)'], + ['STRING(254)'], + ['WSTRING(8)'], + ['STRING'], + ['INT'], + ])('round-trips %s unchanged', (declared) => { + const xml = convertTypeToXml({ definition: 'base-type', value: declared }) + expect(parseTypeXml(xml)).toEqual({ definition: 'base-type', value: declared }) + }) + + it('round-trips an ARRAY of sized strings, element length included', () => { + const type = { + definition: 'array' as const, + value: 'ARRAY[0..3] OF STRING(23)', + data: { + baseType: { definition: 'base-type' as const, value: 'STRING(23)' }, + dimensions: [{ dimension: '0..3' }], + }, + } + const xml = convertTypeToXml(type) + expect(parseTypeXml(xml).data?.baseType).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + }) + + // An importer meeting a foreign file must not turn it into a type nothing + // downstream recognises, so an unusable attribute degrades to the plain type + // rather than failing the load. + it.each([ + ['zero', '0'], + ['past the implementation maximum', '999'], + ['not a number', 'lots'], + ])('ignores a length that is %s', (_label, raw) => { + expect(parseTypeXml({ string: { '@length': raw } })).toEqual({ + definition: 'base-type', + value: 'STRING', + }) + }) + + it('ignores a length on an element that cannot carry one', () => { + expect(parseTypeXml({ INT: { '@length': '4' } })).toEqual({ + definition: 'base-type', + value: 'INT', + }) + }) +}) diff --git a/src/frontend/utils/PLC/array-codegen-helpers.ts b/src/frontend/utils/PLC/array-codegen-helpers.ts index 88986316d..017f70dae 100644 --- a/src/frontend/utils/PLC/array-codegen-helpers.ts +++ b/src/frontend/utils/PLC/array-codegen-helpers.ts @@ -1,4 +1,5 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' +import { parseStringLength } from '../iec-types-registry' import { parseDimensionRange } from './dimension-range' const BASE_TYPE_TO_IEC: Record = { @@ -87,12 +88,60 @@ const getArrayBaseTypeValue = (variable: PLCVariable): string => { * Without `userTypeNames` the bare name is returned, which is the correct * answer for a function block and the historical behaviour for everything else. */ +/** + * The generic type names a native block may declare on a VAR_INPUT, and the one + * runtime type they all resolve to. + * + * All seven share a representation — the `IEC_ANY` descriptor + * `{ typeclass, pvalue, diSize }`. The family constrains what the caller may + * pass, which the compiler checks at the call site, not what the block receives. + */ +const GENERIC_TYPE_TO_IEC: Record = { + // Not a generic: the descriptor a generic carries, declarable in its own + // right so a block can keep what it was handed. Same runtime type. + '__SYSTEM.ANYTYPE': 'IEC_ANY', + + ANY: 'IEC_ANY', + ANY_BIT: 'IEC_ANY', + ANY_DATE: 'IEC_ANY', + ANY_NUM: 'IEC_ANY', + ANY_REAL: 'IEC_ANY', + ANY_INT: 'IEC_ANY', + ANY_STRING: 'IEC_ANY', +} + +/** + * Whether a pin's declared type is a generic (or the descriptor it carries), + * and so resolves to the runtime's `IEC_ANY` rather than to a project type. + */ +const isDescriptorPinType = (typeName: string): boolean => + GENERIC_TYPE_TO_IEC[typeName.toUpperCase()] !== undefined + const mapUserTypeToIEC = (typeName: string, userTypeNames?: ReadonlySet): string => { const upper = typeName.toUpperCase() + const generic = GENERIC_TYPE_TO_IEC[upper] + if (generic) return generic return userTypeNames?.has(upper) ? `IEC_${upper}` : upper } +/** + * strucpp wrapper for a length-qualified string, or `null` for anything else. + * + * `IEC_STRING` / `IEC_WSTRING` are fixed aliases for the 254-character + * wrappers, so a declared length names the template directly. Must match what + * STruC++ emits for the same declaration (`IECStringVar<23>`): `_VARS` + * points at the member the function block declares, so a mismatch is an ABI + * bug, not a compile error. + */ +const sizedStringIECType = (baseType: string): string | null => { + const { base, length, valid } = parseStringLength(baseType) + if (length === undefined || !valid) return null + return base === 'WSTRING' ? `IECWStringVar<${length}>` : `IECStringVar<${length}>` +} + const mapBaseTypeToIEC = (baseType: string, userTypeNames?: ReadonlySet): string => { + const sized = sizedStringIECType(baseType) + if (sized) return sized const elementary = BASE_TYPE_TO_IEC[baseType.toLowerCase()] if (elementary) return elementary // Not elementary: an array of a user-defined type, or a type the map does not @@ -164,6 +213,39 @@ const multiDimensionalContainerType = (variable: PLCVariable, userTypeNames?: Re return `Array${dimensions.length}D` } +/** The bound a variable-length array dimension carries. */ +const VARIABLE_LENGTH_BOUND = '*' + +/** + * strucpp view type for a variable-length array, or `null` for anything else. + * + * A VLA pin (`ARRAY [*] OF INT`) has no bounds until it is called, so it cannot + * be a pointer to its first element: nothing would carry the element count or + * the lower bound. strucpp passes `ArrayViewD` — data pointer plus runtime + * bounds — reached through `lower_bound()` / `upper_bound()` / `at()`, so the + * struct holds a pointer to the view itself. + * + * Rank one and two only: the runtime declares `ArrayView1D` and `ArrayView2D` + * and nothing beyond. A mixed shape like `ARRAY [*, 0..3]` is not legal and + * falls to the fixed-array path. + */ +const variableLengthViewType = (variable: PLCVariable, userTypeNames?: ReadonlySet): string | null => { + if (variable.type.definition !== 'array' || !variable.type.data) return null + + const dimensions = variable.type.data.dimensions + if (dimensions.length < 1 || dimensions.length > 2) return null + if (!dimensions.every((dimension) => dimension.dimension.trim() === VARIABLE_LENGTH_BOUND)) return null + + const elementType = mapBaseTypeToIEC(variable.type.data.baseType.value, userTypeNames) + return `ArrayView${dimensions.length}D` +} + +/** + * Whether a variable is a variable-length array, and so is passed as a view + * rather than as a pointer to its first element. + */ +const isVariableLengthArray = (variable: PLCVariable): boolean => variableLengthViewType(variable) !== null + /** * Generate a C struct member declaration for a variable. * Both scalars and arrays use pointers: @@ -188,6 +270,9 @@ const multiDimensionalContainerType = (variable: PLCVariable, userTypeNames?: Re */ const generateStructMember = (variable: PLCVariable, userTypeNames?: ReadonlySet): string => { const name = variable.name.toUpperCase() + const variableLength = variableLengthViewType(variable, userTypeNames) + if (variableLength) return ` strucpp::${variableLength} *${name};\n` + const multiDimensional = multiDimensionalContainerType(variable, userTypeNames) if (multiDimensional) return ` strucpp::${multiDimensional} *${name};\n` @@ -202,6 +287,8 @@ export { getArrayTotalElements, getVariableIECType, isArrayVariable, + isDescriptorPinType, + isVariableLengthArray, mapBaseTypeToIEC, mapUserTypeToIEC, multiDimensionalContainerType, diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts index 3f9113db1..f59162394 100644 --- a/src/frontend/utils/PLC/data-type-text-parser.ts +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -37,11 +37,11 @@ const enumRegex = /^(?\w+)\s*:\s*\((?[^)]*)\)\s*(?::=\s*(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*)\s*(?::=\s*(?[^;]+?))?\s*;$/i + /^(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*(?:\s*[([]\s*\d+\s*[)\]])?)\s*(?::=\s*(?[^;]+?))?\s*;$/i // FieldName : Type := Initial ; (* documentation *) const fieldRegex = - /^(?\w+)\s*:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^(?\w+)\s*:\s*(?[\w\s[\](),.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ const guessErrorReason = (line: string): string => { if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' diff --git a/src/frontend/utils/PLC/generic-types.ts b/src/frontend/utils/PLC/generic-types.ts new file mode 100644 index 000000000..2925463a5 --- /dev/null +++ b/src/frontend/utils/PLC/generic-types.ts @@ -0,0 +1,46 @@ +/** + * Generic type names, and how they cross the PLCopen XML boundary. + * + * PLCopen TC6 v2.01 puts these in the `elementaryTypes` group, so `` and + * `` are element tags of their own, not ``. + * + * The editor models them as `user-data-type`, not `base-type`: `base-type` + * values are validated against `baseTypeSchema`, the elementary registry, and a + * generic has no width, no wire format and nothing to debug. + */ + +/** + * The ten names PLCopen TC6 v2.01 defines, in schema order. + * + * Three more than a POU may be declared with: `ANY_DERIVED`, `ANY_ELEMENTARY` + * and `ANY_MAGNITUDE` classify types without being declarable. They still round + * trip, and the compiler is what refuses them, naming the type. + */ +const PLCOPEN_GENERIC_TYPES = [ + 'ANY', + 'ANY_DERIVED', + 'ANY_ELEMENTARY', + 'ANY_MAGNITUDE', + 'ANY_NUM', + 'ANY_REAL', + 'ANY_INT', + 'ANY_BIT', + 'ANY_STRING', + 'ANY_DATE', +] as const + +const GENERIC_TYPE_SET: ReadonlySet = new Set(PLCOPEN_GENERIC_TYPES) + +/** Whether a type name is one of the PLCopen generic types. Case-insensitive. */ +const isGenericType = (typeName: string): boolean => GENERIC_TYPE_SET.has(typeName.trim().toUpperCase()) + +/** + * The canonical spelling of a generic type name, or `null` if it is not one. + * PLCopen writes these upper-case, so the tag and the name are the same text. + */ +const canonicalGenericType = (typeName: string): string | null => { + const upper = typeName.trim().toUpperCase() + return GENERIC_TYPE_SET.has(upper) ? upper : null +} + +export { canonicalGenericType, isGenericType, PLCOPEN_GENERIC_TYPES } diff --git a/src/frontend/utils/PLC/global-variable-list-text-parser.ts b/src/frontend/utils/PLC/global-variable-list-text-parser.ts index 5cbdbba7d..1dee69f70 100644 --- a/src/frontend/utils/PLC/global-variable-list-text-parser.ts +++ b/src/frontend/utils/PLC/global-variable-list-text-parser.ts @@ -56,7 +56,7 @@ const commentOnlyRegex = /^\(\*[\s\S]*\*\)$/ // CODESYS converter writes a GVL declaration in — expecting it after the type made // this parser reject the very declarations the importer produces. const declarationRegex = - /^(?\w+(?:\s*,\s*\w+)*)\s*(?:AT\s+(?%[\w.]+)\s*)?:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/i + /^(?\w+(?:\s*,\s*\w+)*)\s*(?:AT\s+(?%[\w.]+)\s*)?:\s*(?[\w\s[\](),.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/i function buildVariableType(typeStr: string): PLCVariableType | null { const arrayType = parseArrayType(typeStr) diff --git a/src/frontend/utils/PLC/pou-signature-serializer.ts b/src/frontend/utils/PLC/pou-signature-serializer.ts index 0661b6ba4..b8515727f 100644 --- a/src/frontend/utils/PLC/pou-signature-serializer.ts +++ b/src/frontend/utils/PLC/pou-signature-serializer.ts @@ -77,6 +77,11 @@ function buildDeclarationLine(pou: PLCPou): string { if (pou.pouType === 'function' && pou.interface?.returnType) { return `${startKeyword} ${pou.name} : ${pou.interface.returnType}` } + // The LSP needs the base too: without it a derived block appears to have only + // its own pins and methods, so every inherited one reads as undefined. + if (pou.interface?.extends) { + return `${startKeyword} ${pou.name} EXTENDS ${pou.interface.extends}` + } return `${startKeyword} ${pou.name}` } diff --git a/src/frontend/utils/PLC/pou-text-parser.ts b/src/frontend/utils/PLC/pou-text-parser.ts index 6a0c099aa..9731c0be9 100644 --- a/src/frontend/utils/PLC/pou-text-parser.ts +++ b/src/frontend/utils/PLC/pou-text-parser.ts @@ -8,16 +8,20 @@ import { getLanguageFromExtension } from './pou-file-extensions' * @returns Object with documentation and remaining content */ const extractDocumentation = (content: string): { documentation: string; remainingContent: string } => { - const docMatch = content.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) - if (docMatch) { - return { - documentation: docMatch[1].trim(), - remainingContent: content.slice(docMatch[0].length), - } + // A comment is legal wherever whitespace is, so a header may be written as + // several consecutive blocks. Taking only the first leaves the rest in + // front of the declaration, which the declaration regex then fails to match. + const blocks: string[] = [] + let remainingContent = content + for (;;) { + const docMatch = remainingContent.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) + if (!docMatch) break + blocks.push(docMatch[1].trim()) + remainingContent = remainingContent.slice(docMatch[0].length) } return { - documentation: '', - remainingContent: content, + documentation: blocks.join('\n\n'), + remainingContent, } } @@ -35,24 +39,33 @@ const formatParseError = (message: string, lineNumber?: number): string => { return `Parse error: ${message}` } +/** A `VAR` section opening, with or without its qualifier. */ +const VAR_SECTION_START = /^\s*VAR(_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL|_GLOBAL|_ACCESS)?\b/i + /** - * Helper function to find the last END_VAR in the content - * @param content - The content to search - * @param startIndex - The index to start searching from - * @returns The index after the last END_VAR, or -1 if not found + * End of a POU's declaration region — the index just past the `END_VAR` that + * closes the last consecutive `VAR` section, or -1 when none opens. + * + * Consumes `VAR` sections one at a time and stops at the first thing that is + * not one, rather than taking the last `END_VAR` anywhere in the file. The + * difference matters because the body that follows can contain the keyword: + * a graphical POU's body is JSON, and a block node in it may carry a native + * function block's source, `END_VAR` and all. Scanning to the end lands the + * split inside that JSON string, and the file the editor wrote a moment ago + * no longer parses. */ export const findLastEndVarIndex = (content: string, startIndex: number): number => { - let lastEndVarIndex = -1 - let searchIndex = startIndex - - let endVarMatch = content.slice(searchIndex).match(/\bEND_VAR\b/i) - while (endVarMatch && endVarMatch.index !== undefined) { - lastEndVarIndex = searchIndex + endVarMatch.index + endVarMatch[0].length - searchIndex = lastEndVarIndex - endVarMatch = content.slice(searchIndex).match(/\bEND_VAR\b/i) + let cursor = startIndex + let declarationEnd = -1 + + while (VAR_SECTION_START.test(content.slice(cursor))) { + const endVarMatch = content.slice(cursor).match(/\bEND_VAR\b/i) + if (!endVarMatch || endVarMatch.index === undefined) break + cursor += endVarMatch.index + endVarMatch[0].length + declarationEnd = cursor } - return lastEndVarIndex + return declarationEnd } /** @@ -78,7 +91,13 @@ export const parseTextualPouFromString = (content: string, language: string, typ throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -87,6 +106,7 @@ export const parseTextualPouFromString = (content: string, language: string, typ const pouName = declarationMatch[2] const returnType = declarationMatch[3] // Only present for functions + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -138,6 +158,7 @@ export const parseTextualPouFromString = (content: string, language: string, typ pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -177,7 +198,13 @@ export const parseHybridPouFromString = (content: string, language: string, type throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -186,6 +213,7 @@ export const parseHybridPouFromString = (content: string, language: string, type const pouName = declarationMatch[2] const returnType = declarationMatch[3] // Only present for functions + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -235,6 +263,7 @@ export const parseHybridPouFromString = (content: string, language: string, type pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -275,7 +304,13 @@ export const parseGraphicalPouFromString = (content: string, language: string, t throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -284,6 +319,7 @@ export const parseGraphicalPouFromString = (content: string, language: string, t const pouName = declarationMatch[2] const returnType = declarationMatch[3] + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -345,6 +381,7 @@ export const parseGraphicalPouFromString = (content: string, language: string, t pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { diff --git a/src/frontend/utils/PLC/pou-text-serializer.ts b/src/frontend/utils/PLC/pou-text-serializer.ts index 2b1c31a7c..59c6b38c5 100644 --- a/src/frontend/utils/PLC/pou-text-serializer.ts +++ b/src/frontend/utils/PLC/pou-text-serializer.ts @@ -17,6 +17,10 @@ const buildDeclaration = (pou: SerializablePou): string => { if (pou.pouType === 'function' && pou.interface?.returnType) { return `${startKeyword} ${pou.name} : ${pou.interface.returnType}\n` } + // The base must survive the round trip, or saving a derived POU un-derives it. + if (pou.interface?.extends) { + return `${startKeyword} ${pou.name} EXTENDS ${pou.interface.extends}\n` + } return `${startKeyword} ${pou.name}\n` } diff --git a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts index cfc8a18a9..7a4901dab 100644 --- a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts +++ b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts @@ -1,4 +1,4 @@ -import { lookupBaseType } from '../../iec-types-registry' +import { lookupBaseType, parseStringLength } from '../../iec-types-registry' /** * Pick the PLCopen TC6 0201 XML element tag for a base type value. @@ -33,3 +33,16 @@ export const baseTypeTag = (value: string): string => { export const isPlcopenStandardType = (value: string): boolean => { return lookupBaseType(value)?.xml.plcopenStandard ?? false } + +/** + * Body of a base-type element: the empty string for everything, or the TC6 + * `length` attribute for a declared string length. + * + * TC6 carries the length as an attribute, so `STRING(23)` is + * ``. Without it a save/load round trip drops the + * declaration back to the 254-character default. + */ +export const baseTypeElementBody = (value: string): '' | { '@length': string } => { + const { length, valid } = parseStringLength(value) + return length !== undefined && valid ? { '@length': String(length) } : '' +} diff --git a/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts index 1f67fb6fb..11279272b 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts @@ -1,6 +1,7 @@ import { PLCDataType } from '@root/middleware/shared/ports/open-plc-types' import { BaseXml } from '@root/middleware/shared/ports/xml-types/codesys' +import { isGenericType } from '../../generic-types' import { baseTypeTag } from '../base-type-tag' const parseDimensions = (dimensions: Array<{ dimension: string }>) => { @@ -78,7 +79,9 @@ export const codeSysParseDataTypesToXML = (xml: BaseXml, dataTypes: PLCDataType[ return { '@name': variable.name, type: { - [baseTypeTag(variable.type.value)]: '', + [isGenericType(variable.type.value) + ? variable.type.value.trim().toUpperCase() + : baseTypeTag(variable.type.value)]: '', }, initialValue: variable.initialValue?.simpleValue.value ? { diff --git a/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts index e97625fb7..f4a7c4095 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts @@ -6,6 +6,7 @@ import { BaseXml } from '@root/middleware/shared/ports/xml-types/codesys' import { InterfaceXML } from '@root/middleware/shared/ports/xml-types/codesys/pous/interface/interface-diagram' import { VariableXML } from '@root/middleware/shared/ports/xml-types/codesys/variable/variable-diagram' +import { isGenericType } from '../../generic-types' import { baseTypeTag } from '../base-type-tag' import { fbdToXml } from './language/fbd-xml' import { ilToXML } from './language/il-xml' @@ -42,6 +43,10 @@ export const codeSysParseInterface = (pou: PLCPou) => { }, }, } + } else if (isGenericType(variable.type.value)) { + // A generic is its own element in the schema's elementaryTypes group; + // `` would name a user type called ANY instead. + vType = { [variable.type.value.trim().toUpperCase()]: '' } } else if (variable.type.definition === 'derived' || variable.type.definition === 'user-data-type') { vType = { derived: { @@ -81,7 +86,11 @@ export const codeSysParseInterface = (pou: PLCPou) => { if (!xml.returnType) xml.returnType = {} const isBaseType = baseTypes.includes(returnType) - xml.returnType = isBaseType ? { [baseTypeTag(returnType)]: '' } : { ['derived']: { '@name': returnType } } + xml.returnType = isGenericType(returnType) + ? { [returnType.trim().toUpperCase()]: '' } + : isBaseType + ? { [baseTypeTag(returnType)]: '' } + : { ['derived']: { '@name': returnType } } } switch (variable.class) { diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts index 33795c8de..29a113805 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts @@ -1,6 +1,7 @@ import { PLCVariable } from '@root/middleware/shared/ports/open-plc-types' -import { baseTypeTag } from '../base-type-tag' +import { isGenericType } from '../../generic-types' +import { baseTypeElementBody, baseTypeTag } from '../base-type-tag' type VariableType = PLCVariable['type'] @@ -24,12 +25,20 @@ export const convertTypeToXml = (type: VariableType): Record => }), baseType: { [baseTypeKey]: - type.data!.baseType.definition === 'user-data-type' ? { '@name': type.data!.baseType.value } : '', + type.data!.baseType.definition === 'user-data-type' + ? { '@name': type.data!.baseType.value } + : baseTypeElementBody(type.data!.baseType.value), }, }, } } + // A generic has an element of its own in the schema's elementaryTypes group. + // `` would name a user-defined type called ANY instead. + if (isGenericType(type.value)) { + return { [type.value.trim().toUpperCase()]: '' } + } + if (type.definition === 'derived' || type.definition === 'user-data-type') { return { derived: { @@ -38,8 +47,9 @@ export const convertTypeToXml = (type: VariableType): Record => } } - // base-type + // base-type. A declared string length rides on the element as TC6's `length` + // attribute — `` — so it survives a save/load round trip. return { - [baseTypeTag(type.value)]: '', + [baseTypeTag(type.value)]: baseTypeElementBody(type.value), } } diff --git a/src/frontend/utils/PLC/xml-parser/type-xml.ts b/src/frontend/utils/PLC/xml-parser/type-xml.ts index 225c7d0e7..8995e36e2 100644 --- a/src/frontend/utils/PLC/xml-parser/type-xml.ts +++ b/src/frontend/utils/PLC/xml-parser/type-xml.ts @@ -1,5 +1,6 @@ import type { PLCVariableType } from '../../../../middleware/shared/ports/types' -import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { lookupBaseTypeByXmlElement, parseStringLength } from '../../iec-types-registry' +import { canonicalGenericType } from '../generic-types' import { asArray, asRecord, asString } from './xml-node' type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string } @@ -8,6 +9,24 @@ type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string // PLCopen ``/`` element has exactly one child key, which is // either a recognised IEC base-type tag, `derived` (user type/FB reference), // or `array` (nested dimensions + element base type). +/** + * Fold a TC6 `length` attribute into the type name, so `` + * becomes `STRING(23)`. + * + * A length this implementation cannot carry degrades to the unqualified type + * rather than failing the load. + */ +function withDeclaredLength(name: string, elementXml: unknown): string { + const raw = asRecord(elementXml)['@length'] + if (raw === undefined) return name + const candidate = `${name}(${asString(raw).trim()})` + // Both halves matter: `parseStringLength` reports `valid: true` with no + // `length` for an unqualified name, so checking `valid` alone would admit + // `` as a type named "STRING(lots)". + const { length, valid } = parseStringLength(candidate) + return length !== undefined && valid ? candidate : name +} + function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { const rec = asRecord(baseTypeXml) if ('derived' in rec) { @@ -15,7 +34,10 @@ function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { } const tag = Object.keys(rec)[0] if (tag === undefined) throw new Error('Type element has no recognizable base type') - return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } + const generic = canonicalGenericType(tag) + if (generic) return { definition: 'user-data-type', value: generic } + const name = lookupBaseTypeByXmlElement(tag)?.name ?? tag + return { definition: 'base-type', value: withDeclaredLength(name, rec[tag]) } } function parseDimensionsXml(dimensionXml: unknown): Array<{ dimension: string }> { @@ -42,7 +64,13 @@ export function parseTypeXml(typeXml: unknown): PLCVariableType { const tag = Object.keys(type)[0] if (tag === undefined) throw new Error('Variable type element is empty') - return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } + // A generic is an elementaryTypes element in the schema, but not a base type + // here — `base-type` values are validated against the elementary registry, + // which a generic is deliberately absent from. See `generic-types.ts`. + const generic = canonicalGenericType(tag) + if (generic) return { definition: 'user-data-type', value: generic } + const name = lookupBaseTypeByXmlElement(tag)?.name ?? tag + return { definition: 'base-type', value: withDeclaredLength(name, type[tag]) } } export { parseBaseTypeLeaf, parseDimensionsXml } diff --git a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts index 065c249b8..4128cd8bf 100644 --- a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts +++ b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts @@ -293,27 +293,65 @@ describe('parseIecStringToVariables', () => { }) describe('a declared string length', () => { - // Legal IEC and legal CODESYS, and STruC++ does not accept it. Left alone it - // was not even recognised as a string: it became a user data type literally - // named "STRING[20]", emitted verbatim into the generated ST, where the - // compiler failed with `Expected Semicolon, found [` on a line the user - // never wrote. - it('is refused, naming the type and what to use instead', () => { - expect(() => parseIecStringToVariables('VAR\n s : STRING[20];\nEND_VAR')).toThrow( - /A declared length is not supported on STRING — use plain STRING, which carries up to 126 characters/, - ) + // STruC++ emits `IECStringVar<23>` — 54 bytes against 518 for the + // unqualified type. + it('is accepted in the standard parenthesised form', () => { + const result = parseIecStringToVariables('VAR\n s : STRING(23);\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + }) + + it('accepts WSTRING too', () => { + const result = parseIecStringToVariables('VAR\n s : WSTRING(8);\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'WSTRING(8)' }) + }) + + // Square brackets are long-established in the field, so they are read and + // normalised rather than refused — a project stores one spelling whichever + // the user typed. + it('normalises the bracket form to the standard one', () => { + const result = parseIecStringToVariables('VAR\n s : STRING[20];\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(20)' }) + }) + + it('is read whatever the spacing and case', () => { + const result = parseIecStringToVariables('VAR\n s : string ( 12 );\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(12)' }) }) - it('is refused for WSTRING too', () => { - expect(() => parseIecStringToVariables('VAR\n s : WSTRING[8];\nEND_VAR')).toThrow(/not supported on WSTRING/) + it('accepts the bounds themselves', () => { + const one = parseIecStringToVariables('VAR\n s : STRING(1);\nEND_VAR') + const max = parseIecStringToVariables('VAR\n s : STRING(254);\nEND_VAR') + + expect(one[0].type.value).toBe('STRING(1)') + expect(max[0].type.value).toBe('STRING(254)') }) - it('is refused whatever the spacing and case', () => { - expect(() => parseIecStringToVariables('VAR\n s : string [ 12 ];\nEND_VAR')).toThrow(/not supported on STRING/) + // Writing the shape is what commits you to a length. Each of these would + // otherwise become a user data type literally named "STRING[]", persisted + // and emitted verbatim into the generated ST, where the compiler fails at a + // column the user never wrote. + it.each([ + ['an empty length', 'STRING[]'], + ['a non-numeric length', 'STRING(abc)'], + ['zero', 'STRING(0)'], + ['past the implementation maximum', 'STRING(255)'], + ])('is refused for %s, rather than becoming a stranger type', (_label, declared) => { + expect(() => parseIecStringToVariables(`VAR\n s : ${declared};\nEND_VAR`)).toThrow(/takes a length from 1 to 254/) }) - it('is refused when the length is empty, rather than becoming a stranger type', () => { - expect(() => parseIecStringToVariables('VAR\n s : STRING[];\nEND_VAR')).toThrow(/not supported on STRING/) + // The element form needs `parseArrayType` to admit a length after `OF`; + // without it this matched nothing and fell through to the compiler, which + // reported `Expected Semicolon, found [` at a column the user never wrote. + it('carries a length on an ARRAY element type', () => { + const result = parseIecStringToVariables('VAR\n tags : ARRAY[0..3] OF STRING(23);\nEND_VAR') + + expect(result[0].type.definition).toBe('array') + expect(result[0].type.data?.baseType).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + expect(result[0].type.data?.dimensions).toEqual([{ dimension: '0..3' }]) }) it('still accepts a plain STRING', () => { @@ -611,4 +649,51 @@ describe('parseIecStringToVariables', () => { expect(result[0].class).toBe('input') }) + + // ---- variable-length arrays ---- + + it('parses a variable-length array, the bound a VLA carries', () => { + // `ARRAY [*] OF INT`, legal as a function block's in-out variable. The type + // group excluded `*`, so the line matched nothing and the POU loaded with no + // variables at all. + const result = parseIecStringToVariables('VAR_IN_OUT\n values : ARRAY [*] OF INT;\nEND_VAR') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('values') + expect(result[0].class).toBe('inOut') + expect(result[0].type).toEqual({ + definition: 'array', + value: 'ARRAY [*] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '*' }], + }, + }) + }) + + it('parses a two-dimensional variable-length array', () => { + const result = parseIecStringToVariables('VAR_IN_OUT\n grid : ARRAY [*,*] OF REAL;\nEND_VAR') + + expect(result[0].type.data?.dimensions).toEqual([{ dimension: '*' }, { dimension: '*' }]) + }) + + it('does not read an empty bound as a variable-length one', () => { + // `*` is a bound; nothing is not. `ARRAY []` still declines to parse as an + // array — it carries no dimension for the array path to read — so it lands + // as a named type rather than being mistaken for `ARRAY [*]`. + const result = parseIecStringToVariables('VAR\n bad : ARRAY [] OF INT;\nEND_VAR') + + expect(result[0].type.definition).not.toBe('array') + expect(result[0].type.value).toBe('ARRAY [] OF INT') + }) + + it('does not mistake a comment for a type', () => { + // `(` stays outside the type character class, so widening it for `*` could + // not let a `(*` comment be read as one. + const result = parseIecStringToVariables('VAR\n count : INT; (* how many *)\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'INT' }) + expect(result[0].documentation).toBe('how many') + }) + }) diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 329453b4b..775bbad67 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -2,8 +2,11 @@ import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, + isLengthQualifiedType, lookupBaseType, lookupBaseTypeByXmlElement, + MAX_STRING_LENGTH, + parseStringLength, } from '../iec-types-registry' describe('iec-types-registry', () => { @@ -134,4 +137,88 @@ describe('iec-types-registry', () => { }) }) }) + + // The length is parenthesised in the declaration, bounded by the capacity of + // the unqualified type. + describe('a declared string length', () => { + it('splits the standard parenthesised form', () => { + expect(parseStringLength('STRING(23)')).toEqual({ base: 'STRING', length: 23, valid: true }) + }) + + it('accepts the bracket form and reports the same base', () => { + expect(parseStringLength('STRING[23]')).toEqual({ base: 'STRING', length: 23, valid: true }) + }) + + it('normalises case and whitespace, as IEC identifiers are case-insensitive', () => { + expect(parseStringLength(' wstring ( 8 ) ')).toEqual({ base: 'WSTRING', length: 8, valid: true }) + }) + + it('reports no length for an unqualified name, without calling it invalid', () => { + // `valid` describes what was written, so "nothing was written" is not an + // error — it is how a caller tells a plain STRING from STRING(0). + expect(parseStringLength('STRING')).toEqual({ base: 'STRING', valid: true }) + expect(parseStringLength('INT')).toEqual({ base: 'INT', valid: true }) + }) + + it.each([ + ['zero', 'STRING(0)'], + ['past the implementation maximum', `STRING(${MAX_STRING_LENGTH + 1})`], + ])('rejects %s', (_label, declared) => { + expect(parseStringLength(declared).valid).toBe(false) + }) + + it('rejects a length on a type that cannot carry one', () => { + // `INT(4)` is not a narrower integer — only STRING and WSTRING are + // length-qualified, so this must not resolve to the INT metadata. + expect(parseStringLength('INT(4)').valid).toBe(false) + expect(lookupBaseType('INT(4)')).toBeUndefined() + }) + + it('leaves an ARRAY declaration alone', () => { + // The regex must anchor on a bare identifier plus a length, or an inline + // array would be mistaken for one. + expect(parseStringLength('ARRAY[0..3] OF INT').length).toBeUndefined() + }) + }) + + describe('lookupBaseType with a declared length', () => { + it('resolves to the STRING metadata, so every existing caller keeps working', () => { + // ~31 call sites across 12 files — baseTypeTag, the XML emitters, the + // debugger decoder, the force encoder — ask this one function what a type + // is. Stripping the length here is what keeps them all unchanged. + expect(lookupBaseType('STRING(23)')).toBe(lookupBaseType('STRING')) + expect(lookupBaseType('WSTRING(8)')?.name).toBe('WSTRING') + }) + + it('keeps the XML element name, which is what the emitters need', () => { + expect(lookupBaseType('STRING(23)')?.xml.elementName).toBe('string') + }) + + it('returns undefined for a length it cannot carry', () => { + expect(lookupBaseType('STRING(0)')).toBeUndefined() + expect(lookupBaseType('STRING(999)')).toBeUndefined() + }) + + it('is reflected by isBaseTypeName', () => { + expect(isBaseTypeName('STRING(23)')).toBe(true) + expect(isBaseTypeName('STRING(0)')).toBe(false) + }) + }) + + // The type dropdown asks this to decide which rows get a length box. + describe('isLengthQualifiedType', () => { + it('is true for STRING and WSTRING only', () => { + expect(isLengthQualifiedType('STRING')).toBe(true) + expect(isLengthQualifiedType('WSTRING')).toBe(true) + for (const name of ['INT', 'DINT', 'REAL', 'BOOL', 'TIME', 'ARRAY']) { + expect(isLengthQualifiedType(name)).toBe(false) + } + }) + + it('normalises case and surrounding whitespace, like the rest of the registry', () => { + expect(isLengthQualifiedType(' string ')).toBe(true) + expect(isLengthQualifiedType('WString')).toBe(true) + }) + }) + }) diff --git a/src/frontend/utils/__tests__/pou-helpers.test.ts b/src/frontend/utils/__tests__/pou-helpers.test.ts index a33829b8b..6910513dc 100644 --- a/src/frontend/utils/__tests__/pou-helpers.test.ts +++ b/src/frontend/utils/__tests__/pou-helpers.test.ts @@ -1,6 +1,7 @@ import type { PLCDataType, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { + findArrayDataType, findFunctionBlockExternalVariables, findFunctionBlockVariables, findLeafVariables, @@ -204,6 +205,65 @@ describe('findFunctionBlockVariables', () => { expect(names).not.toContain('TMP') expect(names).not.toContain('EXT_REF') }) + + // A derived FB's instance carries every member of its bases, and + // debug-table-gen walks the same chain — so a member missing here is one + // debug-map.json offers and the watch panel cannot reach. + describe('EXTENDS', () => { + const fb = (name: string, base: string | undefined, names: string[]): PLCPou => ({ + name, + pouType: 'function-block', + interface: { + ...(base ? { extends: base } : {}), + variables: names.map((n) => ({ + name: n, + class: 'local' as const, + type: { definition: 'base-type' as const, value: 'INT' }, + location: '', + documentation: '', + })), + }, + body: { language: 'st', value: '' }, + }) + + it('includes inherited members, base first', () => { + const pous = [fb('BASE_FB', undefined, ['TICK', 'COUNT']), fb('FAST_FB', 'BASE_FB', ['SELF_KIND'])] + const vars = findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS) + expect(vars!.map((v) => v.name)).toEqual(['TICK', 'COUNT', 'SELF_KIND']) + }) + + it('walks a chain more than one level deep', () => { + const pous = [fb('A_FB', undefined, ['A1']), fb('B_FB', 'A_FB', ['B1']), fb('C_FB', 'B_FB', ['C1'])] + expect(findFunctionBlockVariables('C_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['A1', 'B1', 'C1']) + }) + + it('lets a derived declaration hide the base one of the same name', () => { + const base = fb('BASE_FB', undefined, ['KIND']) + const derived = fb('FAST_FB', 'BASE_FB', ['KIND']) + derived.interface!.variables[0].type = { definition: 'base-type', value: 'REAL' } + const vars = findFunctionBlockVariables('FAST_FB', [base, derived], SYSTEM_LIBS) + expect(vars!.map((v) => v.name)).toEqual(['KIND']) + expect(vars![0].type.value).toBe('REAL') + }) + + it('resolves the base name case-insensitively', () => { + const pous = [fb('BASE_FB', undefined, ['TICK']), fb('FAST_FB', 'base_fb', ['SELF_KIND'])] + expect(findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual([ + 'TICK', + 'SELF_KIND', + ]) + }) + + it('stops at a base that is not a project POU', () => { + const pous = [fb('FAST_FB', 'NOT_A_POU', ['SELF_KIND'])] + expect(findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['SELF_KIND']) + }) + + it('ends a cyclic chain instead of hanging', () => { + const pous = [fb('A_FB', 'B_FB', ['A1']), fb('B_FB', 'A_FB', ['B1'])] + expect(findFunctionBlockVariables('A_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['B1', 'A1']) + }) + }) }) // --------------------------------------------------------------------------- @@ -250,6 +310,22 @@ describe('findFunctionBlockExternalVariables', () => { // SR is a system-library FB; its externals are not project-visible. expect(findFunctionBlockExternalVariables('SR', [])).toEqual([]) }) + + it('picks up a base FB’s externals, listing one declared at both levels once', () => { + const base: PLCPou = { + name: 'BaseFB', + pouType: 'function-block', + interface: { variables: [makeVar('G1', 'external'), makeVar('G2', 'external')] }, + body: { language: 'st', value: '' }, + } + const derived: PLCPou = { + name: 'MyFB', + pouType: 'function-block', + interface: { extends: 'BaseFB', variables: [makeVar('G2', 'external'), makeVar('S', 'local')] }, + body: { language: 'st', value: '' }, + } + expect(findFunctionBlockExternalVariables('MyFB', [base, derived]).map((v) => v.name)).toEqual(['G2', 'G1']) + }) }) // --------------------------------------------------------------------------- @@ -302,6 +378,64 @@ describe('findStructureVariables', () => { }) }) +// --------------------------------------------------------------------------- +// findArrayDataType +// --------------------------------------------------------------------------- + +describe('findArrayDataType', () => { + const arrayOf = (name: string, baseType: { definition: string; value: string }) => + ({ + name, + derivation: 'array', + baseType, + dimensions: [{ dimension: '0..7' }], + }) as unknown as PLCDataType + + const dataTypes: PLCDataType[] = [ + arrayOf('A_PROFILE', { definition: 'base-type', value: 'REAL' }), + arrayOf('A_MOTORS', { definition: 'user-data-type', value: 'S_MOTOR' }), + arrayOf('A_TIMERS', { definition: 'derived', value: 'TON' }), + arrayOf('A_GRID', { definition: 'array', value: 'ARRAY [0..1] OF INT' }), + { name: 'S_MOTOR', derivation: 'structure', variable: [] }, + { name: 'E_MODE', derivation: 'enumerated', values: [{ description: 'OFF' }] }, + ] + + it('returns the element type and bounds, case-insensitively', () => { + expect(findArrayDataType('a_profile', dataTypes)).toEqual({ + baseType: { definition: 'base-type', value: 'REAL' }, + dimensions: [{ dimension: '0..7' }], + }) + }) + + it('carries a structure element through unchanged', () => { + expect(findArrayDataType('A_MOTORS', dataTypes)?.baseType).toEqual({ + definition: 'user-data-type', + value: 'S_MOTOR', + }) + }) + + // The array walk disambiguates an FB from a structure by name, so a `derived` + // element reaches it as a user-data-type rather than a shape it cannot type. + it('reports a function-block element as a user data type', () => { + expect(findArrayDataType('A_TIMERS', dataTypes)?.baseType).toEqual({ + definition: 'user-data-type', + value: 'TON', + }) + }) + + // A nested array needs a second subscript this shape cannot express; a leaf + // is better than elements reported with the wrong bounds. + it('returns null for an array of arrays', () => { + expect(findArrayDataType('A_GRID', dataTypes)).toBeNull() + }) + + it('returns null for a structure, an enumeration and an unknown name', () => { + expect(findArrayDataType('S_MOTOR', dataTypes)).toBeNull() + expect(findArrayDataType('E_MODE', dataTypes)).toBeNull() + expect(findArrayDataType('NoSuchType', dataTypes)).toBeNull() + }) +}) + // --------------------------------------------------------------------------- // isStructureType // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/__tests__/variable-sizes.test.ts b/src/frontend/utils/__tests__/variable-sizes.test.ts index 2424e46dd..6c36fc14e 100644 --- a/src/frontend/utils/__tests__/variable-sizes.test.ts +++ b/src/frontend/utils/__tests__/variable-sizes.test.ts @@ -567,11 +567,28 @@ describe('encodeForceValue', () => { expect(() => encodeForceValue('10', 'TIME')).toThrow(/Invalid TIME value/) }) - it('encodes STRING as a length byte followed by ASCII', () => { - expect(Array.from(encodeForceValue('hi', 'STRING'))).toEqual([2, 0x68, 0x69]) + it('encodes STRING as a length byte followed by ASCII, in the full wire window', () => { + // The window is fixed at `1 + DEBUG_STRING_CAP`, not `1 + text.length`. + // `handle_set` in the runtime compares the received length against + // `type_ops[tag].size` and answers STATUS_DATA_TOO_LARGE below it, so a + // variable-length buffer made every string force a silent no-op: the flag + // showed as set from the session's own bookkeeping, and the value never + // moved. + const WIRE = 1 + 126 + + const hi = encodeForceValue('hi', 'STRING') + expect(hi.length).toBe(WIRE) + expect(Array.from(hi.subarray(0, 3))).toEqual([2, 0x68, 0x69]) + // The tail is zero — the reader decodes `min(length, CAP)` and ignores it. + expect(Array.from(hi.subarray(3)).every((b) => b === 0)).toBe(true) + // IEC literal quotes are unwrapped — and are how spaces survive trimming. - expect(Array.from(encodeForceValue("' hi '", 'STRING'))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) - expect(Array.from(encodeForceValue("''", 'STRING'))).toEqual([0]) + const spaced = encodeForceValue("' hi '", 'STRING') + expect(Array.from(spaced.subarray(0, 5))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) + + const empty = encodeForceValue("''", 'STRING') + expect(empty.length).toBe(WIRE) + expect(Array.from(empty).every((b) => b === 0)).toBe(true) }) it('rejects STRING values that are non-ASCII or over the protocol cap', () => { diff --git a/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts b/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts index 34c562291..63a9b69a0 100644 --- a/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts +++ b/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts @@ -1,7 +1,7 @@ import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { generateSTCode } from '../generateSTCode' -const makeScalarVar = (name: string, cls: 'input' | 'output', baseType: string): PLCVariable => ({ +const makeScalarVar = (name: string, cls: 'input' | 'output' | 'inOut', baseType: string): PLCVariable => ({ name, class: cls, type: { definition: 'base-type', value: baseType }, @@ -182,6 +182,33 @@ describe('generateSTCode (cpp)', () => { expect(result).toContain('vars.IOVAL = &IOVAL;') }) + it('passes a variable-length array as the view itself, not as an element pointer', () => { + // strucpp types an `ARRAY [*] OF INT` pin `ArrayView1D`, which + // carries the runtime bounds. Offsetting to the first element would drop the + // length and index `data_[0 - lower]`, out of range for a non-zero lower + // bound. + const values: PLCVariable = { + name: 'values', + class: 'inOut', + type: { + definition: 'array', + value: 'ARRAY [*] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '*' }], + }, + }, + location: '', + documentation: '', + debug: false, + } + + const result = generateSTCode({ pouName: 'VlaBlock', allVariables: [values] }) + + expect(result).toContain('vars.VALUES = &VALUES;') + expect(result).not.toContain('&VALUES[') + }) + describe('VAR_EXTERNAL', () => { const ext = (name: string): PLCVariable => ({ name, diff --git a/src/frontend/utils/cpp/generateSTCode.ts b/src/frontend/utils/cpp/generateSTCode.ts index d237df04a..ec8c0ccf5 100644 --- a/src/frontend/utils/cpp/generateSTCode.ts +++ b/src/frontend/utils/cpp/generateSTCode.ts @@ -1,5 +1,10 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' -import { getArrayStartIndex, isArrayVariable, multiDimensionalContainerType } from '../PLC/array-codegen-helpers' +import { + getArrayStartIndex, + isArrayVariable, + isVariableLengthArray, + multiDimensionalContainerType, +} from '../PLC/array-codegen-helpers' import { cBlockExternalVariables, cBlockInterfaceVariables } from './block-interface' type STCodeGenerationParams = { @@ -28,10 +33,17 @@ type STCodeGenerationParams = { * index in one `operator()` call — so the container itself is passed and the * block indexes it as `grid(i, j)`, the same accessor the compiler's own * generated code uses. + * + * - Variable-length arrays: `vars.NAME = &NAME`, for the same reason as a + * multi-dimensional one. The pin is an `ArrayViewD`, whose bounds are not + * known until the call, so passing the first element would drop the only + * record of how many elements there are — and there is no lower bound yet to + * offset by. Passing the view keeps `lower_bound()` / `upper_bound()` / `at()` + * reachable from the block. */ const generateVariableAssignment = (variable: PLCVariable): string => { const name = variable.name.toUpperCase() - if (multiDimensionalContainerType(variable)) { + if (multiDimensionalContainerType(variable) || isVariableLengthArray(variable)) { return `vars.${name} = &${name};\n` } if (isArrayVariable(variable)) { diff --git a/src/frontend/utils/debug-tree-traversal.ts b/src/frontend/utils/debug-tree-traversal.ts index 6014b07bc..e776a1ff1 100644 --- a/src/frontend/utils/debug-tree-traversal.ts +++ b/src/frontend/utils/debug-tree-traversal.ts @@ -16,6 +16,7 @@ import { findDebugVariableForField, } from './debug-variable-finder' import { + findArrayDataType, findFunctionBlockExternalVariables, findFunctionBlockVariables, findStructureVariables, @@ -269,6 +270,14 @@ function traverseNestedNode( return visitor.visitComplex(name, fullPath, compositeKey, typeName, children) } else if (typeDefinition === 'user-data-type') { + // An array data type (`TYPE A_PROFILE : ARRAY [0..7] OF REAL`) is a user + // data type by name and an array by shape, and STruC++ emits its elements + // as `NAME[i]` like any other array. Without this it collapses to one leaf. + const arrayDataType = findArrayDataType(typeName, dataTypes) + if (arrayDataType) { + return traverseNestedNode(name, fullPath, compositeKey, typeName, 'array', context, visitor, arrayDataType) + } + // Structure type — STruC++ emits struct fields as `PARENT.FIELD` // (same convention as FB fields), no `.value.` shim. const structVariables = findStructureVariables(typeName, dataTypes) diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index 210884ca0..e135bb81b 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -1,7 +1,7 @@ import type { LibraryState } from '../../middleware/shared/ports/library-types' import { baseTypeSchema } from '../../middleware/shared/ports/plc-schemas' import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' -import { DEBUG_STRING_CAP } from './variable-sizes' +import { MAX_STRING_LENGTH, parseStringLength } from './iec-types-registry' const varBlockToClass: Record = { VAR: 'local', @@ -46,17 +46,23 @@ export const DISALLOWED_LOCATION_CLASSES: ReadonlyArray = // `parseArrayType` has declined it, and `parseArrayType` declines blank bounds. // Both guards are below; between them, a comma reaches the store only as part of // a well-formed multi-dimensional ARRAY. +// +// It also accepts `*`, the bound of a variable-length array +// (`values : ARRAY [*] OF INT;`), and `(` / `)` for a declared string length +// (`name : STRING(23);`). The parentheses cannot swallow a `(*` comment: the +// group is lazy and must be followed by `;`, and anything before that +// semicolon is rejected by `baseTypeSchema` and `identifierRegex`. // Primary format: name : type AT location := initialValue ; (* documentation *) const lineRegex = // eslint-disable-next-line no-useless-escape - /^\s*(?\w+)\s*:\s*(?[\w\s\[\],\.]+?)(?:\s+AT\s+(?[\w\d\._%]+))?\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^\s*(?\w+)\s*:\s*(?[\w\s\[\]\(\),\.\*]+?)(?:\s+AT\s+(?[\w\d\._%]+))?\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ // Alternate format: name AT location : type := initialValue ; (* documentation *) // This format is used by some IEC 61131-3 tools and older versions of OpenPLC Editor const alternateLineRegex = // eslint-disable-next-line no-useless-escape - /^\s*(?\w+)\s+AT\s+(?[\w\d\._%]+)\s*:\s*(?[\w\s\[\],\.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^\s*(?\w+)\s+AT\s+(?[\w\d\._%]+)\s*:\s*(?[\w\s\[\]\(\),\.\*]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ const guessErrorReason = (line: string): string => { if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' @@ -81,8 +87,12 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type * Also consumed by the data-type text parser (`PLC/data-type-text-parser.ts`). */ export const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { - // Match ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally namespaced) - const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*)\s*$/i) + // ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally + // namespaced) that may carry a declared string length — + // `ARRAY [0..3] OF STRING(23)`. + const arrayMatch = typeStr.match( + /^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*(?:\s*[([]\s*\d+\s*[)\]])?)\s*$/i, + ) if (!arrayMatch) return null const dimensionsStr = arrayMatch[1] @@ -214,33 +224,29 @@ export const parseIecStringToVariables = ( ) } - // A length-qualified string (`STRING[20]`, `WSTRING[8]`) is legal IEC and - // legal CODESYS, and STruC++ does not accept it. Left alone it is not even - // recognised as a string: it becomes a user data type literally named - // "STRING[20]", which is persisted, shown in the type cell, and emitted - // verbatim into the generated ST — where the compiler fails with - // `Expected Semicolon, found [` pointing at a line the user never wrote. + // A length-qualified string — `STRING(23)`, `WSTRING(8)`. STruC++ emits + // `IECStringVar<23>` at 54 bytes where a plain STRING is 518. Square + // brackets are accepted and normalised to the parenthesised form. // - // Refusing here says what is true today. The transport carries a fixed - // DEBUG_STRING_CAP-character budget, so a declared length would not be honoured even if - // it parsed; when the compiler grows the declaration, this guard is the one - // place that has to change. - // Both shapes it can take: on its own (`msg : STRING[20]`) and as an ARRAY's - // element type (`tags : ARRAY [0..3] OF STRING[20]`). The array form needs - // its own alternative because `parseArrayType` only accepts a bare - // identifier after `OF`, so a length-qualified element matches nothing and - // used to fall through every branch to the compiler — which then reported - // `Expected Semicolon, found [` at a column the user never wrote, plus two - // cascading errors on the FOLLOWING line, so even the line number misled. - const lengthQualifiedString = - /^(W?STRING)\s*\[\s*[^\]]*\]$/i.exec(parsedType) ?? - /^ARRAY\s*\[[^\]]*\]\s+OF\s+(W?STRING)\s*\[\s*[^\]]*\]\s*$/i.exec(parsedType) - if (lengthQualifiedString) { - const keyword = lengthQualifiedString[1].toUpperCase() - throw new Error( - `Syntax error on line ${lineNumber}: "${line}". A declared length is not supported on ${keyword} — ` + - `use plain ${keyword}, which carries up to ${DEBUG_STRING_CAP} characters.`, - ) + // Only a malformed or out-of-range length is refused, and refused here + // rather than left to fall through: an unrecognised type is stored as a + // user data type named "STRING(0)" and emitted verbatim into generated ST. + // + // The array element form is handled by `parseArrayType` above. + const stringWithLength = /^(W?STRING)\s*[([]\s*([^)\]]*?)\s*[)\]]$/i.exec(parsedType) + if (stringWithLength) { + // Matching the shape commits to a length, so `STRING[]`, `STRING(abc)`, + // `STRING(0)` and `STRING(999)` are all reported here. `parseStringLength` + // returns `valid: true` with no length for an unqualified name, so the + // undefined case must be caught explicitly. + const { length, valid } = parseStringLength(parsedType) + if (length === undefined || !valid) { + throw new Error( + `Syntax error on line ${lineNumber}: "${line}". ` + + `${stringWithLength[1].toUpperCase()} takes a length from 1 to ${MAX_STRING_LENGTH}, ` + + `got "${stringWithLength[2]}".`, + ) + } } const baseCheck = baseTypeSchema.safeParse(parsedType.toUpperCase()) diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index 30574982d..1d6569e24 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -92,6 +92,44 @@ const INDEX: ReadonlyMap = (() => { return m })() +/** + * Largest declared length a STRING / WSTRING may carry — the capacity of the + * unqualified type, so `STRING` and `STRING(254)` are the same declaration. + */ +export const MAX_STRING_LENGTH = 254 + +/** Only these two carry a declared length. */ +const LENGTH_QUALIFIED = new Set(['STRING', 'WSTRING']) + +/** Whether a declared length may be written after this type name. */ +export function isLengthQualifiedType(name: string): boolean { + return LENGTH_QUALIFIED.has(name.trim().toUpperCase()) +} + +/** + * Split a declared type into its base name and its optional length. + * `STRING(23)` is the form STruC++ parses; `STRING[23]` is accepted and + * normalised to it. + * + * `length` is undefined when none was written; `valid` is false for a length + * this implementation cannot carry, so callers can tell `STRING` from + * `STRING(0)`. + */ +export function parseStringLength(name: string): { + base: string + length?: number + valid: boolean +} { + const trimmed = name.trim() + const match = /^([A-Za-z_]\w*)\s*[([]\s*(\d+)\s*[)\]]$/.exec(trimmed) + if (!match) return { base: trimmed.toUpperCase(), valid: true } + + const base = match[1].toUpperCase() + const length = Number(match[2]) + const valid = LENGTH_QUALIFIED.has(base) && length >= 1 && length <= MAX_STRING_LENGTH + return { base, length, valid } +} + /** * Resolve a name (canonical or alias, any case, with surrounding * whitespace) to its metadata. Returns `undefined` for non-elementary @@ -100,9 +138,14 @@ const INDEX: ReadonlyMap = (() => { * `'STRING'`, `'string'`, and the occasional whitespace-padded value; * normalising here means downstream callers don't each re-implement * it. + * + * A declared length is stripped first, so `STRING(23)` resolves to the STRING + * metadata. Callers wanting the number itself use {@link parseStringLength}. */ export function lookupBaseType(name: string): IECTypeMetadata | undefined { - return INDEX.get(name.trim().toUpperCase()) + const { base, length, valid } = parseStringLength(name) + if (length !== undefined && !valid) return undefined + return INDEX.get(base) } /** @@ -111,7 +154,7 @@ export function lookupBaseType(name: string): IECTypeMetadata | undefined { * pattern. */ export function isBaseTypeName(name: string): boolean { - return INDEX.has(name.trim().toUpperCase()) + return lookupBaseType(name) !== undefined } /** diff --git a/src/frontend/utils/pou-helpers.ts b/src/frontend/utils/pou-helpers.ts index 9e2aebe29..bed24b727 100644 --- a/src/frontend/utils/pou-helpers.ts +++ b/src/frontend/utils/pou-helpers.ts @@ -88,12 +88,44 @@ export const isBaseType = (typeName: string): boolean => { const LIBRARY_FB_INTERFACE_CLASSES: ReadonlySet = new Set(['input', 'output', 'inOut']) const USER_FB_PERSISTENT_CLASSES: ReadonlySet = new Set(['input', 'output', 'inOut', 'local']) +/** + * Find a user-defined function block POU by type name. + */ +const findProjectFunctionBlock = (typeName: string, projectPous: PLCPou[]): PLCPou | undefined => { + const typeNameUpper = typeName.toUpperCase() + const pou = projectPous.find( + (p) => normalizeTypeString(p.pouType) === 'functionblock' && p.name.toUpperCase() === typeNameUpper, + ) + return pou?.pouType === 'function-block' ? pou : undefined +} + +/** + * The `EXTENDS` chain above a user-defined function block, derived first. + * Stops at a base that is not a project POU; `visited` bounds a cycle. + */ +const functionBlockChain = (typeName: string, projectPous: PLCPou[]): PLCPou[] => { + const chain: PLCPou[] = [] + const visited = new Set() + let cursor = findProjectFunctionBlock(typeName, projectPous) + while (cursor && !visited.has(cursor.name.toUpperCase())) { + visited.add(cursor.name.toUpperCase()) + chain.push(cursor) + const base = cursor.interface?.extends + if (!base) break + cursor = findProjectFunctionBlock(base, projectPous) + } + return chain +} + /** * Find a function block definition by name. * Searches BOTH the built-in library AND project POUs. * Returns the variables array from the FB definition, filtered to * match the debugger's variable-enumeration contract (see comment on * LIBRARY_FB_INTERFACE_CLASSES). Returns null if not found. + * + * For a user-defined FB the result spans the whole `EXTENDS` chain, base + * members first, with a derived declaration hiding a base one of the same name. */ export const findFunctionBlockVariables = ( typeName: string, @@ -123,16 +155,22 @@ export const findFunctionBlockVariables = ( // Check project POUs (user-defined FBs) — interface + locals, // dropping temp / external. - const customFB = projectPous.find( - (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + const chain = functionBlockChain(typeName, projectPous) + if (chain.length === 0) return null + + // Claim derived-first so a redeclaration wins, emit base-first. + const claimed = new Set() + const perLevel = chain.map((pou) => + ((pou.interface?.variables ?? []) as PouVariable[]).filter((v) => { + if (v.class !== undefined && !USER_FB_PERSISTENT_CLASSES.has(v.class)) return false + const key = v.name.toUpperCase() + if (claimed.has(key)) return false + claimed.add(key) + return true + }), ) - if (customFB && customFB.pouType === 'function-block') { - return ((customFB.interface?.variables ?? []) as PouVariable[]).filter((v) => - v.class === undefined ? true : USER_FB_PERSISTENT_CLASSES.has(v.class), - ) - } - return null + return perLevel.reverse().flat() } /** @@ -151,16 +189,21 @@ export const findFunctionBlockVariables = ( * * Returns full PLCVariable objects (not the narrowed PouVariable view) so the * debug traversal can hand them straight to traverseVariable. + * + * The `EXTENDS` chain is walked too, deduped by name: every level resolves to + * the same canonical `Config0:` node. */ export const findFunctionBlockExternalVariables = (typeName: string, projectPous: PLCPou[]): PLCVariable[] => { - const typeNameUpper = typeName.toUpperCase() - const customFB = projectPous.find( - (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + const seen = new Set() + return functionBlockChain(typeName, projectPous).flatMap((pou) => + (pou.interface?.variables ?? []).filter((v) => { + if (v.class !== 'external') return false + const key = v.name.toUpperCase() + if (seen.has(key)) return false + seen.add(key) + return true + }), ) - if (customFB && customFB.pouType === 'function-block') { - return (customFB.interface?.variables ?? []).filter((v) => v.class === 'external') - } - return [] } /** @@ -193,6 +236,33 @@ export const isStructureType = (typeName: string, dataTypes: PLCDataType[]): boo return findStructureVariables(typeName, dataTypes) !== null } +/** + * Find an array data type by name — `TYPE A_PROFILE : ARRAY [0..7] OF REAL`. + * Returns the element type and bounds in the same shape an inline + * `ARRAY [..] OF ..` carries in `type.data`. Null for anything else. + */ +export const findArrayDataType = ( + typeName: string, + dataTypes: PLCDataType[], +): { + baseType: { definition: 'base-type' | 'user-data-type'; value: string } + dimensions: Array<{ dimension: string }> +} | null => { + const dataType = dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase()) + if (dataType?.derivation !== 'array') return null + + // `baseType` is a full PLCVariableType, wider than the array walk accepts. + // `derived` names an FB, which the walk resolves by name anyway; a nested + // `array` element needs a second subscript this shape cannot carry. + const { definition, value } = dataType.baseType + if (definition !== 'base-type' && definition !== 'user-data-type' && definition !== 'derived') return null + + return { + baseType: { definition: definition === 'derived' ? 'user-data-type' : definition, value }, + dimensions: dataType.dimensions, + } +} + /** * Check if a type name is an enumeration. */ diff --git a/src/frontend/utils/variable-sizes.ts b/src/frontend/utils/variable-sizes.ts index 466f83088..037d29d0a 100644 --- a/src/frontend/utils/variable-sizes.ts +++ b/src/frontend/utils/variable-sizes.ts @@ -431,7 +431,12 @@ function encodeByWireFormat(originalInput: string, numericInput: string, meta: I if (text.length > DEBUG_STRING_CAP) { throw new Error(`STRING value too long: ${text.length} characters (max ${DEBUG_STRING_CAP})`) } - const buf = new Uint8Array(1 + text.length) + // The full wire window, not `1 + text.length`: `handle_set` compares the + // received length against `type_ops[tag].size` — the fixed 127-byte + // window for a STRING — and answers STATUS_DATA_TOO_LARGE below it, so a + // short buffer makes the force a silent no-op. The zero tail is ignored; + // the reader decodes `min(length, CAP)` units from the prefix. + const buf = new Uint8Array(1 + DEBUG_STRING_CAP) buf[0] = text.length for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i) diff --git a/src/main/main.ts b/src/main/main.ts index 5ae531b3e..759b62d8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -174,6 +174,18 @@ const createMainWindow = async () => { // Load the Url or index.html file; void mainWindow.loadURL(resolveHtmlPath('index.html')) + // `npm run dev` starts Electron and webpack-dev-server in parallel. On a + // slower machine Electron wins the race, gets ERR_CONNECTION_REFUSED, and + // never retries — the splash closes onto a blank window. Retry until the + // dev server answers. ERR_ABORTED (-3) is a superseded navigation, not a + // failure, and retrying it would fight the navigation that replaced it. + if (isDebug) { + mainWindow.webContents.on('did-fail-load', (_event, errorCode) => { + if (errorCode === -3) return + setTimeout(() => void mainWindow?.loadURL(resolveHtmlPath('index.html')), 500) + }) + } + // Save window bounds on resize, close, and move events const saveBounds = () => { store.set('window.bounds', mainWindow?.getBounds()) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 105fc8805..bc474d510 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -69,6 +69,11 @@ import { import { PackageManagerModule } from '../../../backend/editor/package-manager' import { RuntimeApiClient } from '../../../backend/editor/runtime/runtime-api-client' import { logger } from '../../../backend/editor/services' +import { + addLibraryResource, + listLibraryResources, + removeLibraryResource, +} from '../../../backend/editor/services/library-resources-service' import { getOpenProjectPath, getPlcopenExportSavePath, @@ -707,6 +712,11 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('file:watch-stop', this.handleFileWatchStop) this.registerHandle('file:watch-stop-all', this.handleFileWatchStopAll) this.registerHandle('file:read-content', this.handleFileReadContent) + + // ===================== LIBRARY RESOURCES ===================== + this.registerHandle('library-resources:list', this.handleLibraryResourcesList) + this.registerHandle('library-resources:add', this.handleLibraryResourcesAdd) + this.registerHandle('library-resources:remove', this.handleLibraryResourcesRemove) } // ===================== HANDLER METHODS ===================== @@ -2507,6 +2517,47 @@ class MainProcessBridge implements MainIpcModule { }) } + // ===================== LIBRARY RESOURCES HANDLERS ===================== + // + // A library project's `resources/` directory, managed from the Build + // Settings dialog. Every path is derived from the open project rather + // than taken from the renderer, so there is nothing here for a compromised + // renderer to point somewhere else — the one renderer-supplied string is a + // folder name, which the service checks as a path component. + + handleLibraryResourcesList = async () => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + try { + return { success: true, folders: await listLibraryResources(this.currentProjectPath) } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + handleLibraryResourcesAdd = async () => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + if (!this.mainWindow) return { success: false, error: 'No main window' } + const picked = await dialog.showOpenDialog(this.mainWindow, { + title: 'Add library folder to resources', + properties: ['openDirectory'], + }) + if (picked.canceled || picked.filePaths.length === 0) return { success: false, canceled: true } + try { + return await addLibraryResource(this.currentProjectPath, picked.filePaths[0]) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + handleLibraryResourcesRemove = async (_event: IpcMainInvokeEvent, folderName: string) => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + try { + return await removeLibraryResource(this.currentProjectPath, folderName) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + // ===================== EVENT HANDLERS ===================== mainIpcEventHandlers = { handleUpdateTheme: (_event: unknown, theme?: 'light' | 'dark' | 'nineties') => { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 076d523c0..a4c41e439 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -700,6 +700,23 @@ const rendererProcessBridge = { return () => ipcRenderer.removeListener('simulator:stopped', listener) }, + // ===================== LIBRARY RESOURCES METHODS ===================== + // A library project's `resources/` folders. The main process derives every + // path from the open project, so none is passed from here. + libraryResourcesList: (): Promise<{ + success: boolean + folders?: Array<{ name: string; files: string[] }> + error?: string + }> => ipcRenderer.invoke('library-resources:list'), + libraryResourcesAdd: (): Promise<{ + success: boolean + canceled?: boolean + folder?: { name: string; files: string[] } + error?: string + }> => ipcRenderer.invoke('library-resources:add'), + libraryResourcesRemove: (folderName: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('library-resources:remove', folderName), + // ===================== FILE WATCHER METHODS ===================== fileWatchStart: (filePath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('file:watch-start', filePath), diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index 21b6ca888..1b18d6475 100644 --- a/src/middleware/adapters/editor/compiler-adapter.ts +++ b/src/middleware/adapters/editor/compiler-adapter.ts @@ -55,6 +55,7 @@ export interface IpcProjectData { name: string variables: unknown[] returnType?: string + extends?: string body: { language: string; value: unknown } documentation: string } @@ -74,6 +75,9 @@ function portPouToIpcPou(pou: PLCPou) { name: pou.name, variables: (pou.interface?.variables ?? []) as unknown[], ...(pou.interface?.returnType ? { returnType: pou.interface.returnType } : {}), + // Restated field by field, so anything unnamed is dropped — `extends` + // included, and the derived block then reaches the compiler with no base. + ...(pou.interface?.extends ? { extends: pou.interface.extends } : {}), body: pou.body as { language: string; value: unknown }, documentation: pou.documentation ?? '', }, diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 5af2c5d88..e7787162b 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -41,6 +41,8 @@ interface IpcPou { language?: string variables: unknown[] returnType?: string + /** Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. */ + extends?: string body: { language: string; value: unknown } documentation: string variablesText?: string @@ -82,6 +84,9 @@ interface IpcPouResponse { /** * Maps editor discriminated-union POU to port flat POU format. + * + * Both mappings name every field they carry, so anything unlisted is dropped — + * `extends` included, or the base is lost on load. */ function mapIpcPouToPortPou(ipcPou: IpcPou): PLCPou { return { @@ -89,6 +94,7 @@ function mapIpcPouToPortPou(ipcPou: IpcPou): PLCPou { pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType, + ...(ipcPou.data.extends ? { extends: ipcPou.data.extends } : {}), variables: ipcPou.data.variables as PLCVariable[], }, body: ipcPou.data.body as PLCPou['body'], @@ -107,6 +113,7 @@ function mapPortPouToIpcPou(portPou: PLCPou): IpcPou { language: portPou.body.language, variables: (portPou.interface?.variables ?? []) as unknown[], ...(portPou.interface?.returnType ? { returnType: portPou.interface.returnType } : {}), + ...(portPou.interface?.extends ? { extends: portPou.interface.extends } : {}), body: portPou.body as { language: string; value: unknown }, documentation: portPou.documentation ?? '', }, @@ -350,6 +357,18 @@ export function createEditorProjectAdapter(): ProjectPort { }) }, + async listLibraryResources() { + return window.bridge.libraryResourcesList() + }, + + async addLibraryResource() { + return window.bridge.libraryResourcesAdd() + }, + + async removeLibraryResource(folderName: string) { + return window.bridge.libraryResourcesRemove(folderName) + }, + async pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> { const response = await window.bridge.pickPlcopenImportFile() if (!response.success) { diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index 60af8db42..eaa0a192b 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -35,6 +35,7 @@ * server-side, but the pipeline never knows. */ +import type { BundleFile } from '../utils/library/bundle-file' import type { PLCProjectData, StructuredCompileError } from './types' /** @@ -118,8 +119,12 @@ export interface CompileArduinoArgs { * `src/c_blocks.h`, `examples/Baremetal/c_blocks_code.cpp`, * `src/defines.h`, and the bundled firmware skeleton + strucpp * runtime headers. On editor: written to disk before - * arduino-cli runs. On web: POSTed in the request body. */ - files: Record + * arduino-cli runs. On web: POSTed in the request body. + * + * Almost every entry is generated text. An entry carrying bytes — a + * library's precompiled `.a` — arrives as `{ base64 }` and must be written + * decoded; see `BundleFile`. */ + files: Record /** Argv suffix for arduino-cli compile (after the `compile` * subcommand). Comes from the shared `buildArduinoCliCompileArgs` * helper. */ @@ -148,8 +153,9 @@ export interface CompileArduinoResult { export interface UploadRuntimeV4Args { /** File map the runtime extracts on the device. Already * composed by `composeRuntimeV4Bundle`; the pipeline passes it - * straight through. */ - bundle: Record + * straight through. A `{ base64 }` entry carries bytes — see + * `BundleFile`. */ + bundle: Record /** Discriminated device context; see `PlatformDeviceContext`. */ context: PlatformDeviceContext } @@ -397,8 +403,9 @@ export interface CompilerPlatformPort { } export interface MaterializeRuntimeV4BundleArgs { - /** Path → file content, as composed by `composeRuntimeV4Bundle`. */ - bundle: Record + /** Path → file content, as composed by `composeRuntimeV4Bundle`. A + * `{ base64 }` entry carries bytes — see `BundleFile`. */ + bundle: Record } export interface MaterializeRuntimeV4BundleResult { diff --git a/src/middleware/shared/ports/index.ts b/src/middleware/shared/ports/index.ts index cbad8507f..f42471946 100644 --- a/src/middleware/shared/ports/index.ts +++ b/src/middleware/shared/ports/index.ts @@ -60,7 +60,7 @@ export type { DevicePort } from './device-port' export type { NavigationPort, NavigationSearch } from './navigation-port' export { buildNavigationUrl } from './navigation-port' export type { OrchestratorPort } from './orchestrator-port' -export type { ProjectPort } from './project-port' +export type { LibraryResourceFolder, ProjectPort } from './project-port' export type { RuntimePort } from './runtime-port' export type { SimulatorPort } from './simulator-port' export type { StlibSource, StlibSourcePort } from './stlib-source-port' diff --git a/src/middleware/shared/ports/library-build-port.ts b/src/middleware/shared/ports/library-build-port.ts index 3134a05e5..e9f21ba63 100644 --- a/src/middleware/shared/ports/library-build-port.ts +++ b/src/middleware/shared/ports/library-build-port.ts @@ -31,8 +31,8 @@ import type { TranspileToStArgs, TranspileToStResult } from './compiler-platform-port' /** - * Outcome of an attempted verification compile against the OpenPLC - * Simulator board. Verification is advisory: a `success: false` + * Outcome of an attempted verification compile. Verification is + * advisory: a `success: false` * surfaces as a warning on the build result, never as a fatal error * (the `.stlib` still ships). See `runLibraryBuildPipeline` for the * cache + skip-on-md5-match flow that wraps this. @@ -61,6 +61,26 @@ export interface LibraryArchiveLookupArgs { projectLibraryRefs: ReadonlyArray<{ name: string; version: string }> } +/** + * Which toolchain a library is verified with. Authored in `library.json`'s + * `build` block through the Build Settings dialog; the orchestrator reads it + * and hands it to the port, which owns the catalogue lookup that turns a core + * into something its platform can compile. + */ +export interface LibraryVerifyTarget { + /** + * `arduino` compiles the library's C++ with the Arduino toolchain for + * `core` — the only mode that checks the C++. `runtime` transpiles and + * composes a Runtime v4 bundle, which checks the ST and the bundle but not + * the C++, because a runtime compiles its own upload. `off` skips + * verification. + */ + mode: 'arduino' | 'runtime' | 'off' + /** Arduino core to compile against (`esp32:esp32`). Absent leaves the + * choice to the port. */ + core?: string +} + export interface VerifyCompileArgs { /** Project root path on the host platform. Same value the build * orchestrator received; the port impl knows how to interpret it. */ @@ -79,6 +99,9 @@ export interface VerifyCompileArgs { * before threading into the IPC envelope). */ verifyProjectData: unknown + /** Toolchain to verify against, resolved from the manifest by the + * orchestrator. The port maps it onto its own board catalogue. */ + target: LibraryVerifyTarget /** Caller log callback. Every line the inner compile emits is * forwarded here; the orchestrator prefixes them with `[verify]` * before forwarding to its own caller. */ @@ -129,6 +152,31 @@ export interface LibraryBuildPort { */ writeBuildFile(projectPath: string, relPath: string, content: string): Promise + /** + * List every file under a project-relative directory, recursively. Paths + * are relative to `relPath`, `/`-separated and sorted, so the result is + * identical across platforms. Returns `[]` when the directory is absent. + */ + listProjectFiles(projectPath: string, relPath: string): Promise + + /** + * Names of the directories directly inside a project-relative directory, + * sorted. Returns `[]` when the directory is absent. + * + * One level, not recursive: a caller listing the library folders under + * `resources/` never descends into an author's `build/` or `.git/`. + */ + listProjectDirs(projectPath: string, relPath: string): Promise + + /** + * Read a project-relative file as raw bytes, base64-encoded, or `null` when + * it is absent. + * + * `readBuildFile` decodes as UTF-8 and cannot carry a precompiled `.a`. + * Text still goes through `readBuildFile`; this is for what is not text. + */ + readBuildFileBase64(projectPath: string, relPath: string): Promise + /** * Recursively remove a project-relative subtree. No-op when the * subtree doesn't exist. Implementations MUST scope deletion to @@ -153,13 +201,15 @@ export interface LibraryBuildPort { loadLibraryArchives(args: LibraryArchiveLookupArgs): Promise /** - * Run a verification compile of `verifyProjectData` against the - * OpenPLC Simulator board. Both platform impls internally drive - * the shared `runCompilePipeline` — the only thing they own is the - * platform-specific arg assembly (board entry, hals data, firmware - * skeleton) and the transport. Failures are advisory: the - * orchestrator surfaces them as a warning on the build result, - * never as a fatal error. + * Run a verification compile of `verifyProjectData` against `target`. + * Both platform impls internally drive the shared `runCompilePipeline` — + * the only thing they own is the platform-specific arg assembly (board + * entry, hals data, firmware skeleton) and the transport. Failures are + * advisory: the orchestrator surfaces them as a warning on the build + * result, never as a fatal error. + * + * Never called with `target.mode === 'off'` — the orchestrator skips + * verification entirely in that case. */ verifyCompile(args: VerifyCompileArgs): Promise } diff --git a/src/middleware/shared/ports/library-port.ts b/src/middleware/shared/ports/library-port.ts index 3ca810499..e652b0c79 100644 --- a/src/middleware/shared/ports/library-port.ts +++ b/src/middleware/shared/ports/library-port.ts @@ -106,6 +106,20 @@ export interface StlibArchiveDTO { * is the deliverable and an archive without it is unbuildable. * Located via a function block's `sourceFile`. */ sources?: Array<{ fileName: string; source: string; category?: string }> + /** Files the library ships for its blocks to compile against — + * headers they `#include`, `.cpp` units they need linked. The + * consumer's program build materialises them into its own build + * tree, so a library and its sources cannot drift apart. `path` + * is reproduced verbatim from the library's `resources/` tree. + * Absent on libraries that ship none. */ + resources?: Array<{ + path: string + content: string + /** `'base64'` when `content` carries bytes rather than text — see + * `LibraryResource`. Absent on a text file, so an archive of source-only + * libraries is unchanged. */ + encoding?: 'base64' + }> } export interface LibraryPort { diff --git a/src/middleware/shared/ports/plc-schemas.ts b/src/middleware/shared/ports/plc-schemas.ts index 99308f892..06f883d79 100644 --- a/src/middleware/shared/ports/plc-schemas.ts +++ b/src/middleware/shared/ports/plc-schemas.ts @@ -8,7 +8,7 @@ * Editor: src/types/PLC/units/library.ts * Web: src/store/types/PLC/units/library.ts */ -import { BASE_TYPE_NAMES } from '@root/frontend/utils/iec-types-registry' +import { BASE_TYPE_NAMES, parseStringLength } from '@root/frontend/utils/iec-types-registry' import z from 'zod' /** @@ -29,7 +29,27 @@ import z from 'zod' * names — and don't need to round-trip mixed-case input. */ const baseTypeEnum = z.enum(BASE_TYPE_NAMES as unknown as [string, ...string[]]) -const baseTypeSchema = z.preprocess((v) => (typeof v === 'string' ? v.trim().toUpperCase() : v), baseTypeEnum) + +/** + * A length-qualified string — `STRING(23)`, `WSTRING(8)`. Square brackets are + * accepted on input and normalised to the parenthesised form. + * + * Beside the enum rather than in it: `baseTypeEnum` is also exported for + * `.options` / `.extract()`, and a dropdown lists type names, not declarations. + */ +const sizedStringSchema = z.string().transform((value, ctx) => { + const { base, length, valid } = parseStringLength(value) + if (length === undefined || !valid) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid base type: ${value}` }) + return z.NEVER + } + return `${base}(${length})` +}) + +const baseTypeSchema = z.preprocess( + (v) => (typeof v === 'string' ? v.trim().toUpperCase() : v), + z.union([baseTypeEnum, sizedStringSchema]), +) const genericTypeSchema = z.object({ ANY: z.union([ diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 303e15d53..adf0954e4 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -31,6 +31,17 @@ import type { DeviceConfiguration, DevicePin, PLCProjectData, ProjectMeta, RecentProject, Unsubscribe } from './types' +/** + * One library folder under a library project's `resources/` — the ordinary + * Arduino layout (`library.properties` beside `src/`), packaged verbatim into + * the `.stlib` for the consuming project to compile. + */ +export interface LibraryResourceFolder { + name: string + /** Paths relative to the folder, `/`-separated and sorted. */ + files: string[] +} + export interface CreateProjectParams { name: string type: 'plc-project' | 'plc-library' @@ -358,6 +369,30 @@ export interface ProjectPort { error?: string }> + /** + * The library folders under a library project's `resources/`, each with + * the files it ships. Optional: only a library project has the + * directory, and only the desktop editor manages it today. + */ + listLibraryResources?(): Promise<{ success: boolean; folders?: LibraryResourceFolder[]; error?: string }> + + /** + * Ask the user for a library folder and copy it into `resources/`. + * `canceled` distinguishes a dismissed picker from a failure, so the + * caller can stay silent rather than reporting an error the user caused + * on purpose. + * Editor: native open-directory dialog, recursive copy. + */ + addLibraryResource?(): Promise<{ + success: boolean + canceled?: boolean + folder?: LibraryResourceFolder + error?: string + }> + + /** Remove one library folder from `resources/`. */ + removeLibraryResource?(folderName: string): Promise<{ success: boolean; error?: string }> + /** * Pick a PLCopen XML file to import and read its contents. * Editor: native open-file dialog filtered to .xml. diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 191053bfe..bf71a906d 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -151,6 +151,12 @@ export interface PLCPou { pouType: PouType interface?: { returnType?: string + /** + * Base function block, from `FUNCTION_BLOCK X EXTENDS Y`; undefined when + * the POU derives from nothing. On the interface because the clause changes + * the block's pins and methods, as `returnType` does for a FUNCTION. + */ + extends?: string variables: PLCVariable[] } body: PLCBody @@ -1272,12 +1278,12 @@ export interface DebugCompileResult { * shape of `CompileResult` (success / error) plus the artefact path * the console surfaces so the user can find the produced archive. * - * The verification step (Phase 8 — running the synthetic project - * through avr-gcc on the simulator target) reports its outcome - * through `verification`: missing means the step hasn't been wired - * yet; `success: true` means it ran clean; `success: false` does NOT - * fail the build, the warning surfaces to the console instead (a - * legitimate target may have more memory than the AVR simulator). + * The verification step (compiling the synthetic project against the + * manifest's verify target) reports its outcome through `verification`: + * missing means it did not run — `build.verify: "off"`; `success: true` + * means it ran clean; `success: false` does NOT fail the build, the warning + * surfaces to the console instead, because the `.stlib` carries source and + * the consumer compiles it for its own board. */ export interface CompileLibraryResult { success: boolean diff --git a/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts b/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts index b78ab703b..dcc410576 100644 --- a/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts +++ b/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts @@ -26,10 +26,54 @@ function baseInput(overrides: Partial = {}): Compos opcUa: null, ethercat: '{"masters":[]}', }, + libraryResources: [], ...overrides, } } +describe('composeRuntimeV4Bundle — library resources', () => { + it('writes each library folder under libraries/, as it stands', () => { + const files = composeRuntimeV4Bundle( + baseInput({ + libraryResources: [ + { + name: 'DemoProtocol', + files: [ + { path: 'library.properties', content: 'name=DemoProtocol\n' }, + { path: 'src/DemoApi.h', content: '// api\n' }, + { path: 'src/transport/DemoUdp.cpp', content: '// udp\n' }, + ], + }, + ], + }), + ) + // Same layout the firmware bundle uses. Makefile.strucpp puts every + // libraries/*/src on the include path and compiles beneath it, so a block + // resolves `#include ` exactly as it does on Arduino. + expect(files['libraries/DemoProtocol/src/DemoApi.h']).toBe('// api\n') + expect(files['libraries/DemoProtocol/src/transport/DemoUdp.cpp']).toBe('// udp\n') + expect(files['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\n') + }) + + it('cannot collide with a generated artefact, which all sit at the root', () => { + const files = composeRuntimeV4Bundle( + baseInput({ + libraryResources: [ + { + name: 'DemoProtocol', + files: [ + { path: 'generated.cpp', content: '// hijacked\n' }, + { path: 'src/generated.cpp', content: '// also fine\n' }, + ], + }, + ], + }), + ) + expect(files['generated.cpp']).toBe('// generated\n') + expect(files['libraries/DemoProtocol/generated.cpp']).toBe('// hijacked\n') + }) +}) + describe('composeRuntimeV4Bundle', () => { it('writes program.st at the zip root', () => { const files = composeRuntimeV4Bundle(baseInput()) diff --git a/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts b/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts new file mode 100644 index 000000000..652a9595f --- /dev/null +++ b/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts @@ -0,0 +1,67 @@ +import { DEFAULT_VERIFY_TARGET, parseVerifyTarget, withVerifyTarget } from '../manifest-build-block' + +describe('parseVerifyTarget', () => { + it('defaults when the block is absent', () => { + expect(parseVerifyTarget({ name: 'lib' })).toEqual({ target: DEFAULT_VERIFY_TARGET }) + }) + + it('reads mode and core', () => { + expect(parseVerifyTarget({ build: { verify: 'arduino', core: 'esp32:esp32' } })).toEqual({ + target: { mode: 'arduino', core: 'esp32:esp32' }, + }) + }) + + it('reports an unknown mode', () => { + const result = parseVerifyTarget({ build: { verify: 'nope' } }) + expect(result).toEqual({ errors: [expect.stringMatching(/must be one of arduino, runtime, off/) as string] }) + }) + + it('reports a non-object block', () => { + expect(parseVerifyTarget({ build: [] })).toEqual({ + errors: [expect.stringMatching(/must be a JSON object/) as string], + }) + }) +}) + +describe('withVerifyTarget', () => { + const manifest = JSON.stringify({ name: 'lib', version: '1.0.0', namespace: 'lib' }, null, 2) + '\n' + + it('adds the block and round-trips through the parser', () => { + const updated = withVerifyTarget(manifest, { mode: 'arduino', core: 'esp32:esp32' }) + expect(updated).not.toBeNull() + expect(parseVerifyTarget(JSON.parse(updated as string) as Record)).toEqual({ + target: { mode: 'arduino', core: 'esp32:esp32' }, + }) + }) + + it('leaves the rest of the manifest and its key order alone', () => { + const updated = withVerifyTarget(manifest, { mode: 'off' }) as string + expect(Object.keys(JSON.parse(updated) as Record)).toEqual([ + 'name', + 'version', + 'namespace', + 'build', + ]) + expect((JSON.parse(updated) as { version: string }).version).toBe('1.0.0') + }) + + it('drops the core when the target no longer names one', () => { + const withCore = withVerifyTarget(manifest, { mode: 'arduino', core: 'esp32:esp32' }) as string + const withoutCore = withVerifyTarget(withCore, { mode: 'arduino' }) as string + expect(JSON.parse(withoutCore)).toMatchObject({ build: { verify: 'arduino' } }) + expect((JSON.parse(withoutCore) as { build: Record }).build).not.toHaveProperty('core') + }) + + it('keeps other keys already in the block', () => { + const seeded = JSON.stringify({ name: 'lib', build: { verify: 'arduino', future: 1 } }, null, 2) + const updated = withVerifyTarget(seeded, { mode: 'runtime' }) as string + expect(JSON.parse(updated)).toMatchObject({ build: { verify: 'runtime', future: 1 } }) + }) + + it('refuses a manifest that is not a JSON object', () => { + // The dialog can be opened while the Manifest tab holds a half-typed + // edit; overwriting it would discard the user's work. + expect(withVerifyTarget('{ not json', { mode: 'off' })).toBeNull() + expect(withVerifyTarget('[]', { mode: 'off' })).toBeNull() + }) +}) diff --git a/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts b/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts new file mode 100644 index 000000000..a2374c56a --- /dev/null +++ b/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts @@ -0,0 +1,34 @@ +import { pickVerifyBoard } from '../pick-verify-board' + +const CANDIDATES = [ + { name: 'OpenPLC Simulator', core: 'arduino:avr', compiler: 'simulator' }, + { name: 'Arduino Uno', core: 'arduino:avr', compiler: 'arduino-cli' }, + { name: 'Arduino Mega', core: 'arduino:avr', compiler: 'arduino-cli' }, + { name: 'ESP32-S3 Dev Module', core: 'esp32:esp32', compiler: 'arduino-cli' }, + { name: 'OpenPLC Runtime v4', compiler: 'openplc-compiler' }, +] + +describe('pickVerifyBoard', () => { + it('prefers a real board over the in-process simulator', () => { + // The simulator is a faked ATmega — a poor stand-in for a core that has + // actual hardware behind it. + expect(pickVerifyBoard(CANDIDATES, 'arduino:avr')).toBe('Arduino Mega') + }) + + it('breaks ties by name, so install order does not change the answer', () => { + const reversed = [...CANDIDATES].reverse() + expect(pickVerifyBoard(reversed, 'arduino:avr')).toBe('Arduino Mega') + }) + + it('returns the only board of a core', () => { + expect(pickVerifyBoard(CANDIDATES, 'esp32:esp32')).toBe('ESP32-S3 Dev Module') + }) + + it('returns null when no board carries the core', () => { + expect(pickVerifyBoard(CANDIDATES, 'rp2040:rp2040')).toBeNull() + }) + + it('falls back to the simulator when it is the only board of the core', () => { + expect(pickVerifyBoard([CANDIDATES[0]], 'arduino:avr')).toBe('OpenPLC Simulator') + }) +}) diff --git a/src/middleware/shared/utils/library/bundle-file.ts b/src/middleware/shared/utils/library/bundle-file.ts new file mode 100644 index 000000000..c41076d96 --- /dev/null +++ b/src/middleware/shared/utils/library/bundle-file.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * One entry in a composed build bundle. + * + * Text is the common case, so a plain `string` stays it. The exception is a + * `precompiled=true` library shipping a `.a`, whose bytes must reach the + * compiler intact. + * + * A union rather than "a string that might be base64", so the compiler finds + * every site that writes a bundle to disk: a missed one writes the base64 text + * as the file's contents. + */ +export type BundleFile = string | { base64: string } + +/** Whether this entry carries bytes rather than text. */ +export function isBinaryBundleFile(file: BundleFile): file is { base64: string } { + return typeof file !== 'string' +} diff --git a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts index 310d83d18..c184764f9 100644 --- a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts +++ b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts @@ -44,6 +44,8 @@ * `//src/` for `boardRuntime === 'openplc-compiler'`. */ +import type { BundleFile } from './bundle-file' + export interface ComposeRuntimeV4BundleInput { /** Concatenated ST program emitted by the ST transpiler. */ programSt: string @@ -55,14 +57,24 @@ export interface ComposeRuntimeV4BundleInput { * generated_debug.cpp, debug-map.json, per-POU *.cpp splits, * program.st.map.json. */ strucppFiles: Record + /** Libraries carried inside the enabled `.stlib` archives, each a + * folder laid out the ordinary way — `library.properties` beside a + * `src/` directory. `path` is relative to that folder's root. + * + * Written under `libraries//`, the same layout the firmware + * bundle uses. `Makefile.strucpp` puts every `libraries//src` on + * the include path and compiles the sources beneath it, so a block + * resolves `#include ` exactly as it does on Arduino. + * Empty for projects with no such libraries. */ + libraryResources: Array<{ name: string; files: Array<{ path: string; content: string; encoding?: 'base64' }> }> /** Pre-rendered C blocks artefacts. The composer treats them as * opaque strings: * - `header`: required. Empty / no-cpp projects pass * `'// Empty file\n'` (matches editor's static stub copied * from `resources/sources/arduino/c_blocks.h`). * - `code`: pass `null` when the project has no C/C++ POUs; the - * runtime build skips the file via wildcard glob. Otherwise - * pass the output of `generateCBlocksCode(originalCppPous)`. */ + * runtime build skips the file. Otherwise pass the output of + * `generateCBlocksCode(originalCppPous)`. */ cBlocks: { header: string code: string | null @@ -97,8 +109,18 @@ export interface ComposeRuntimeV4BundleInput { * Build the file map. Output keys are paths relative to the zip * root (which is what the runtime extracts into `core/generated/`). */ -export function composeRuntimeV4Bundle(input: ComposeRuntimeV4BundleInput): Record { - const files: Record = {} +export function composeRuntimeV4Bundle(input: ComposeRuntimeV4BundleInput): Record { + const files: Record = {} + + // 0. Library folders, written as they stand under `libraries/`. They + // cannot collide with anything generated below — those all sit at + // the zip root. + for (const library of input.libraryResources) { + for (const file of library.files) { + files[`libraries/${library.name}/${file.path}`] = + file.encoding === 'base64' ? { base64: file.content } : file.content + } + } // 1. Concatenated ST program (ST transpiler output) files['program.st'] = input.programSt diff --git a/src/middleware/shared/utils/library/library-folder.ts b/src/middleware/shared/utils/library/library-folder.ts new file mode 100644 index 000000000..9fbef0909 --- /dev/null +++ b/src/middleware/shared/utils/library/library-folder.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * What a folder under `resources/` contributes to the library it holds. + * + * The picker is aimed at a checkout, so the folder arrives with `build/`, + * `.git/`, `test/` and the rest alongside the library. An allow-list rather + * than an exclusion list, because the allowed set is already fixed by the two + * consumers: arduino-cli compiles a 1.5-format library from its `src/`, and the + * Runtime v4 Makefile puts `/src` on the include path. + * + * Shared so the picker and the build walk the folder by the same rule. + */ + +/** The file that makes a folder an Arduino library rather than a directory. */ +export const LIBRARY_PROPERTIES = 'library.properties' + +/** The only directory a library's sources are read from. */ +export const LIBRARY_SRC_DIR = 'src' + +/** The rule, in one sentence, for a message that has to explain a refusal. */ +export const LIBRARY_FOLDER_RULE = `a library folder ships ${LIBRARY_PROPERTIES} and everything under ${LIBRARY_SRC_DIR}/` + +/** + * Whether a path inside a library folder is part of the library. + * + * `relPath` is relative to the folder itself and `/`-separated. + */ +export function isLibraryFile(relPath: string): boolean { + return relPath === LIBRARY_PROPERTIES || relPath.startsWith(`${LIBRARY_SRC_DIR}/`) +} + +/** + * Whether a directory inside a library folder can hold library files, and is + * therefore worth descending into. + * + * `''` is the folder root, which holds `library.properties` and `src` itself. + */ +export function isLibraryDir(relDir: string): boolean { + return relDir === '' || relDir === LIBRARY_SRC_DIR || relDir.startsWith(`${LIBRARY_SRC_DIR}/`) +} diff --git a/src/middleware/shared/utils/library/manifest-build-block.ts b/src/middleware/shared/utils/library/manifest-build-block.ts new file mode 100644 index 000000000..6e5e3d4aa --- /dev/null +++ b/src/middleware/shared/utils/library/manifest-build-block.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * The `build` block in a library project's `library.json`. + * + * Authoring-side settings — which toolchain the library is verified with — + * kept in the manifest because a library project has no device screen to hang + * them on. They stay out of the `.stlib`: `decorateArchive` copies named + * fields onto the archive and this is not one of them, so a consumer never + * sees the author's verify target. + * + * One implementation, two readers: the build reads it through + * `parseVerifyTarget` (errors fail the build) and the Build Settings dialog + * both reads and writes it through `withVerifyTarget`. + */ + +import type { LibraryVerifyTarget } from '../../ports/library-build-port' + +/** Manifest key the dialog writes. */ +export const BUILD_KEY = 'build' + +/** Modes the dialog offers, in the order it lists them. */ +export const VERIFY_MODES = ['arduino', 'runtime', 'off'] as const + +/** What a manifest with no `build` block means. */ +export const DEFAULT_VERIFY_TARGET: LibraryVerifyTarget = { mode: 'arduino' } + +export type ParseVerifyTargetResult = { target: LibraryVerifyTarget } | { errors: string[] } + +/** + * Read `build.verify` / `build.core` off a parsed manifest object. Returns + * the default target when the block is absent, and errors when it is present + * but malformed — a typo that silently verified against a different toolchain + * would report on something the author never asked about. + * + * A `core` naming a toolchain that is not installed is NOT an error here: the + * build warns and falls back, the same way an uninstalled board does. + */ +export function parseVerifyTarget(manifest: Record): ParseVerifyTargetResult { + const raw = manifest[BUILD_KEY] + if (raw === undefined) return { target: DEFAULT_VERIFY_TARGET } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { errors: [`manifest.${BUILD_KEY} must be a JSON object`] } + } + + const build = raw as Record + const errors: string[] = [] + + let mode: LibraryVerifyTarget['mode'] = DEFAULT_VERIFY_TARGET.mode + if (build.verify !== undefined) { + if (!VERIFY_MODES.includes(build.verify as (typeof VERIFY_MODES)[number])) { + errors.push( + `manifest.${BUILD_KEY}.verify must be one of ${VERIFY_MODES.join(', ')}. Got: ${JSON.stringify(build.verify)}`, + ) + } else { + mode = build.verify as LibraryVerifyTarget['mode'] + } + } + + let core: string | undefined + if (build.core !== undefined) { + if (typeof build.core !== 'string' || build.core.length === 0) { + errors.push(`manifest.${BUILD_KEY}.core must be a non-empty string. Got: ${JSON.stringify(build.core)}`) + } else { + core = build.core + } + } + + if (errors.length > 0) return { errors } + return { target: core ? { mode, core } : { mode } } +} + +/** + * `manifestJson` with the `build` block set to `target`. Returns null when + * the text is not a JSON object, so the dialog can say so instead of + * overwriting a manifest the user is midway through editing. + * + * The whole document is re-serialised at two-space indent — the shape the + * editor writes it in — so hand-applied formatting is normalised. Key order + * survives, and JSON carries no comments to lose. + */ +export function withVerifyTarget(manifestJson: string, target: LibraryVerifyTarget): string | null { + let parsed: unknown + try { + parsed = JSON.parse(manifestJson) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null + + const manifest = parsed as Record + const build: Record = { + // Preserve anything else the block carries: this owns two keys, not the + // whole object. + ...((typeof manifest[BUILD_KEY] === 'object' && manifest[BUILD_KEY] !== null && !Array.isArray(manifest[BUILD_KEY]) + ? manifest[BUILD_KEY] + : {}) as Record), + verify: target.mode, + } + // The core is remembered across a mode change, so switching back to Arduino + // does not lose the choice. + if (target.core) build.core = target.core + else delete build.core + + manifest[BUILD_KEY] = build + return JSON.stringify(manifest, null, 2) + '\n' +} diff --git a/src/middleware/shared/utils/library/pick-verify-board.ts b/src/middleware/shared/utils/library/pick-verify-board.ts new file mode 100644 index 000000000..b3238b74d --- /dev/null +++ b/src/middleware/shared/utils/library/pick-verify-board.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Which installed board stands in for a core during library verification. + * + * A library targets a core, not a device — the same thing `library.properties` + * `architectures` names — but arduino-cli needs an FQBN, so one board of that + * core has to represent it. + * + * Shared because both sides need the same answer: the compiler module picks + * the board to compile against, and Build Settings shows the author which one + * that will be. Two implementations would drift and the screen would start + * naming a board the build does not use. + */ + +/** + * The board a verification falls back on, and its core. + * + * The simulator is bundled, so it is the one board always installed — which + * is what makes it the fallback when a target names no core, or names one + * with nothing installed for it. Named here rather than at each use so the + * compiler and the screen that reports its choice cannot drift apart if + * `hals.json` ever renames it. + */ +export const SIMULATOR_BOARD = 'OpenPLC Simulator' +export const SIMULATOR_CORE = 'arduino:avr' + +/** The slice of a board catalogue entry the choice depends on. */ +export interface VerifyBoardCandidate { + name: string + core?: string + compiler?: string +} + +/** + * The board that represents `core`, or null when none is installed. + * + * A real arduino-cli board wins over the in-process simulator — the simulator + * is a faked ATmega and a poor stand-in for a core that has actual hardware + * behind it. Ties break by name, so the choice is stable across runs and does + * not depend on the order packages were installed. + */ +export function pickVerifyBoard(candidates: readonly VerifyBoardCandidate[], core: string): string | null { + const matching = candidates + .filter((candidate) => candidate.core === core) + .sort((a, b) => { + const realA = a.compiler === 'arduino-cli' ? 0 : 1 + const realB = b.compiler === 'arduino-cli' ? 0 : 1 + return realA - realB || a.name.localeCompare(b.name) + }) + return matching[0]?.name ?? null +}