diff --git a/docs/CLI.md b/docs/CLI.md index 781a1a052..ea94fc2d8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -10,6 +10,7 @@ a parallel implementation. | Build | `openplc-cli compile ` | | Build & Upload | `openplc-cli upload ` | | Search / serial-port dropdown | `openplc-cli devices` | +| Library Manager | `openplc-cli library …` | | Debug | `openplc-cli debug open …` | | Start / Stop | `openplc-cli debug start` / `stop` | | Variable poll, force dialog | `openplc-cli debug read` / `force` | @@ -289,6 +290,112 @@ OPENPLC_CREDENTIALS=user:pass # or OPENPLC_USER + OPENPLC_PASSWORD Prefer the environment form in CI: a flag lands in shell history and job logs. +## Libraries + +A library is a `.stlib` archive: blocks, their pin signatures, data types, and +any C/C++ sources they ship. Building one, installing it and choosing which +version a project compiles against are all scriptable. + +```sh +openplc-cli library build [--clean] # project -> .stlib +openplc-cli library install +openplc-cli library uninstall [@] [--all] +openplc-cli library info [@] +openplc-cli library list +openplc-cli library pin @ +openplc-cli library unpin +``` + +A version is named with `@`, not a flag: `--version` is global and prints the +CLI's own version. + +### A ZIP installs every library it holds + +`install` takes a `.zip` as well as a single file, and installs every `.stlib`, +`.lib` and `.library` inside it at any depth — a vendor drop of several +libraries, or one library built for several versions. Each goes through the +same preparer it would have on its own, so a CODESYS file in a ZIP is imported +exactly as picking it directly would. Anything else in the ZIP is ignored, +including the `__MACOSX` forks Finder adds. + +One bad file does not stop the rest: the good ones install and the output names +each one that did not, so a bundle of eight with one corrupt file installs +seven. + +```sh +$ openplc-cli library install vendor-libs.zip +Installed 3 libraries from vendor-libs.zip + libtest-basic 0.1.0 + libtest-basic 0.2.0 + node-uio 0.0.1 +``` + +A ZIP that will not open, or holds no library file at all, fails outright — +that is the wrong file rather than a bad archive. + +### Versions live side by side + +Installing `0.2.0` does not replace `0.1.0`. Both stay, and each project picks +one. `list` names the newest and, once anything has more than one, adds a column +listing them all; the JSON always carries the full array. + +```sh +$ openplc-cli library list +Name Version Installed Origin +libtest-basic 0.2.0 0.2.0, 0.1.0 stlib +``` + +`uninstall` refuses to choose for you when several are installed — name one, or +pass `--all`. Bundled libraries cannot be uninstalled; disable them per project +instead. + +### Pinning + +The pin lives in the project's `project.json` and decides what the compiler +resolves against, so changing it changes the generated code: + +```sh +openplc-cli library pin ./my-project libtest-basic@0.1.0 +openplc-cli compile ./my-project # COUNTER_FB.PV is INT + +openplc-cli library pin ./my-project libtest-basic@0.2.0 +openplc-cli compile ./my-project # COUNTER_FB.PV is REAL +``` + +`pin` refuses a version that is not installed rather than writing a reference the +compiler would quietly substitute later. It rewrites one field of `project.json` +and leaves the rest of the file alone. + +Diagrams already on the canvas are **reported, not rewritten**: + +``` +Repinned libtest-basic 0.1.0 → 0.2.0. + warning: COUNTER_FB: the library added pin RESET (BOOL). 1 placed block in main does not draw it yet. + COUNTER_FB.PV: type INT → REAL — 1 block in main. +``` + +Growing a block needs the editor's own layout engine, so the CLI says what +changed and the GUI applies it the next time the project is opened. A pin type +that changed is honoured by the compiler immediately either way. + +### `info` + +`list` carries identity only. `info` opens the archive and prints what is in it — +which is how you answer "did this pin change between versions" without unpacking +anything: + +```sh +$ openplc-cli library info libtest-basic@0.2.0 +libtest-basic 0.2.0 + namespace: libtest_basic + installed: 0.2.0, 0.1.0 + +Function blocks (1) + COUNTER_FB + in: CU: BOOL, MODE: TEST_MODE, RESET: BOOL + out: Q: BOOL, CV: INT +``` + ## Debug sessions A debug session is long-lived; a test step is one process. So `debug open` starts @@ -332,25 +439,25 @@ point of naming a timeout is that the default was wrong for this run. ### Flags, by command -| Flag | Command | Meaning | -| ---------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------- | -| `--session ` | any `debug` subcommand | which session, when several are open | -| `--idle-timeout ` | `debug open` | idle budget; `0` disables (see above) | -| `--force-new` | `debug open` | start a session even if one is already open for this project and target | -| `--upload-if-needed` | `debug open` | upload first when the target's program does not match | -| `--var ` | `read`, `force`, `unforce` | the variable, when you would rather not pass it positionally | -| `--value ` | `force` | the value — `16#FF`, `TRUE`, `T#5s`, all as the GUI accepts them | -| `--filter ` | `list-vars` | only variables whose path contains it | -| `--interval ` | `watch` | sampling cadence; floor 20 ms | -| `--since ` | `poll` | only samples after this sequence number | -| `--keep-forces` | `close` | leave forced variables pinned | -| `--all` | `close` | every session, not just one | -| `--keep-going` | `exec` | run the remaining lines after one fails | -| `--force` | `create` | overwrite an existing destination | -| `--clean` | `compile`, `upload` | discard the build directory first | -| `--user-data ` | any command | which editor state to use: settings, arduino-cli config, installed packages | -| `-y`, `--yes` | `upload` | skip the confirmation | -| `--create-user` | `upload`, `debug open` | permission to create the FIRST user on a fresh runtime v4, using the credentials you already passed | +| Flag | Command | Meaning | +| ---------------------- | -------------------------- | --------------------------------------------------------------------------------------------------- | +| `--session ` | any `debug` subcommand | which session, when several are open | +| `--idle-timeout ` | `debug open` | idle budget; `0` disables (see above) | +| `--force-new` | `debug open` | start a session even if one is already open for this project and target | +| `--upload-if-needed` | `debug open` | upload first when the target's program does not match | +| `--var ` | `read`, `force`, `unforce` | the variable, when you would rather not pass it positionally | +| `--value ` | `force` | the value — `16#FF`, `TRUE`, `T#5s`, all as the GUI accepts them | +| `--filter ` | `list-vars` | only variables whose path contains it | +| `--interval ` | `watch` | sampling cadence; floor 20 ms | +| `--since ` | `poll` | only samples after this sequence number | +| `--keep-forces` | `close` | leave forced variables pinned | +| `--all` | `close` | every session, not just one | +| `--keep-going` | `exec` | run the remaining lines after one fails | +| `--force` | `create` | overwrite an existing destination | +| `--clean` | `compile`, `upload` | discard the build directory first | +| `--user-data ` | any command | which editor state to use: settings, arduino-cli config, installed packages | +| `-y`, `--yes` | `upload` | skip the confirmation | +| `--create-user` | `upload`, `debug open` | permission to create the FIRST user on a fresh runtime v4, using the credentials you already passed | `watch` **records** into a buffer inside the session rather than streaming, so a transient that happens between two of your own commands is still there when you diff --git a/package.json b/package.json index 07eda624b..cad71e432 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.renderer.prod.ts", "lint": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx}", "lint:fix": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx} --fix", + "typecheck": "tsc --noEmit -p tsconfig.json", "format": "cross-env NODE_ENV=development prettier --write \"./src/**/*.{ts,tsx}\"", "postinstall": "ts-node scripts/download-binaries.ts && ts-node scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "compile:matrix": "ts-node scripts/compile-matrix.ts", 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/App.tsx b/src/App.tsx index 445dc15a4..8d4285c8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,16 @@ hydrateLibraries() // install/uninstall/CDN change. Subscriber lives outside React to // catch events fired before any component mounts. editorPorts.library.onLibrariesChanged(() => hydrateLibraries()) +// And again whenever a project opens. The pool is hydrated once at start-up, +// so a library installed since by another process — `openplc-cli library +// install`, or a second editor — is not in it, and a project that uses that +// library opens reporting it missing; only a restart fixed it. Re-reading is +// enough on its own: `setSystemLibraries` derives the enabled, missing and +// outdated lists from the project's own refs each time it runs. +openPLCStoreBase.subscribe( + (state) => state.project.meta.path, + () => hydrateLibraries(), +) // A provider sign-in finishes in the system browser and lands in the main // process; this is how the account hook hears about it without waiting for diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 1a7e64310..d7a50efe0 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { cp } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { CompilerModule, mergeStandardFlags, standardFlagsForCore } from './compiler-module' @@ -299,7 +299,18 @@ describe('CompilerModule', () => { return null } - const bridge = { loadEnabledArchives: () => ({ archives: [], missing: [] }) } + // The verification compile reaches `compileProgram`, whose bridge contract + // names the runtime-API pair. Neither is called here — the build fails on + // the missing manifest long before — so a throwing stub documents that. + const bridge = { + loadEnabledArchives: () => ({ archives: [], missing: [] }), + makeRuntimeApiRequest: () => { + throw new Error('the library path never talks to a runtime') + }, + makeRuntimeApiUpload: () => { + throw new Error('the library path never uploads') + }, + } as unknown as Parameters[2] const wellFormed = { pous: [], @@ -317,7 +328,7 @@ describe('CompilerModule', () => { const compilerModule = new CompilerModule() const { messages, channel } = makeChannel() - await compilerModule.compileLibrary(['/project', wellFormed, []], channel, bridge) + await compilerModule.compileLibrary(['/project', wellFormed, wellFormed, false, []], channel, bridge) const result = readBuildResult(messages) // It still fails — there is no `library.json` on disk at `/project` — but @@ -337,6 +348,10 @@ describe('CompilerModule', () => { [['/project', { pous: [] }, []], 'no configuration'], [['/project', { pous: [], configuration: {} }, []], 'no resource'], [['/project', { pous: [], configuration: { resource: {} } }, []], 'no task or instance list'], + [['/project', wellFormed, null, false, []], 'no verification project data'], + // The verification payload is a separate argument and gets the same + // shape check as the build payload; an empty object used to pass. + [['/project', wellFormed, {}, false, []], 'verification project data has no POU list'], ])('rejects %p with a result and a closed port', async (args, expected) => { const compilerModule = new CompilerModule() const { messages, isClosed, channel } = makeChannel() diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 4f6d2295b..5cf67720f 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -8,6 +8,7 @@ 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 { LibraryManagerModule } from '@root/backend/editor/library-manager/library-manager-module' import { buildUploadSnapshot } from '@root/backend/editor/project/build-upload-snapshot' import { resolveBuildWorkspace } from '@root/backend/editor/project/cloud-build-workspace' @@ -25,6 +26,7 @@ import { execRecipeArgv } from './recipe-exec' 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 { type NativePouRef, parseNativePouRefs } from '@root/backend/shared/library/native-pou-list' import { buildKnownPous, emitCompileErrorEvents } from '@root/backend/shared/library/program-build-helpers' @@ -36,6 +38,13 @@ 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 type { EnabledArchives, LibraryRef, VersionSubstitution } from '@root/middleware/shared/ports/library-types' +import { + pickVerifyBoard, + SIMULATOR_BOARD, + SIMULATOR_CORE, +} from '@root/middleware/shared/utils/library/pick-verify-board' /** * Bridge contract `compileLibrary` needs from the main process. @@ -47,15 +56,30 @@ import type { KnownPou } from '@root/backend/shared/utils/PLC/split-program-st' * `libraries:` option) and the library build (`compileStlib`'s * dependency list), so there's exactly one resolution path. * - * It used to carry the runtime-API methods too, because the library - * build ran an inner verification compile through `compileProgram`. - * That step is gone, so the library path never touches the runtime - * and the contract narrows to the one call it actually makes. + * The runtime-API methods ride along because the verification compile + * reaches `compileProgram`, whose bridge contract names them. Neither + * is invoked on the library path: it compiles with + * `runtimeIpAddress=null`, so no upload runs. */ type LibraryCompileBridge = { - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + makeRuntimeApiRequest: ( + ipAddress: string, + endpoint: string, + responseParser?: (data: string) => T, + ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives } +type LibraryVerificationBridge = LibraryCompileBridge + /** * Validated form of the `compiler:run-compile-library` payload. * @@ -66,6 +90,8 @@ type LibraryCompileBridge = { type ParsedCompileLibraryArgs = { projectPath: string projectData: PLCProjectData + verifyProjectData: PLCProjectData + cleanBuild: boolean nativePous: NativePouRef[] } @@ -90,46 +116,68 @@ type ParsedCompileLibraryArgs = { function parseCompileLibraryArgs( raw: unknown, ): { ok: true; value: ParsedCompileLibraryArgs } | { ok: false; error: string } { - if (!Array.isArray(raw) || raw.length < 2) { + if (!Array.isArray(raw) || raw.length < 3) { return { ok: false, - error: 'Library build failed: malformed request (expected [projectPath, projectData, nativePous]).', + error: + 'Library build failed: malformed request ' + + '(expected [projectPath, projectData, verifyProjectData, cleanBuild, nativePous]).', } } - const [projectPath, projectData, rawNativePous] = raw as unknown[] + const [projectPath, projectData, verifyProjectData, cleanBuild, rawNativePous] = raw as unknown[] if (typeof projectPath !== 'string' || projectPath === '') { return { ok: false, error: 'Library build failed: request carried no project path.' } } - if (typeof projectData !== 'object' || projectData === null) { - return { ok: false, error: 'Library build failed: request carried no project data.' } + const project = narrowProjectData(projectData, 'project data') + if ('error' in project) return { ok: false, error: project.error } + // The verification pass runs the same pipeline over its own payload, so it + // is held to the same shape. It used to be checked only for being an object, + // on the reasoning that it is the same project — but it arrives as a + // separate argument, and `{}` passed. + const verify = narrowProjectData(verifyProjectData, 'verification project data') + if ('error' in verify) return { ok: false, error: verify.error } + + return { + ok: true, + value: { + projectPath, + projectData: project.value, + verifyProjectData: verify.value, + cleanBuild: cleanBuild === true, + nativePous: parseNativePouRefs(rawNativePous), + }, + } +} + +/** + * Narrow one IPC project payload to the shape the pipeline reaches for. + * + * Everything checked here is read unconditionally downstream; the rest is + * optional to it (`libraries ?? []`, `dataTypes ?? []`), so checking more would + * reject payloads the build handles today. `what` names the payload in the + * message, because two of them cross this call. + */ +function narrowProjectData(value: unknown, what: string): { value: PLCProjectData } | { error: string } { + if (typeof value !== 'object' || value === null) { + return { error: `Library build failed: request carried no ${what}.` } } - const data = projectData as Record + const data = value as Record if (!Array.isArray(data.pous)) { - return { ok: false, error: 'Library build failed: project data has no POU list.' } + return { error: `Library build failed: ${what} has no POU list.` } } const configuration = data.configuration if (typeof configuration !== 'object' || configuration === null) { - return { ok: false, error: 'Library build failed: project data has no configuration.' } + return { error: `Library build failed: ${what} has no configuration.` } } const resource = (configuration as Record).resource if (typeof resource !== 'object' || resource === null) { - return { ok: false, error: 'Library build failed: project configuration has no resource.' } + return { error: `Library build failed: ${what} configuration has no resource.` } } const { tasks, instances } = resource as Record if (!Array.isArray(tasks) || !Array.isArray(instances)) { - return { ok: false, error: 'Library build failed: project resource has no task or instance list.' } - } - return { - ok: true, - value: { - projectPath, - // Shape-checked above for everything the pipeline reaches for. The - // remaining fields are optional to it (`libraries ?? []`, - // `dataTypes ?? []`), so a narrower cast here would buy nothing. - projectData: projectData as PLCProjectData, - nativePous: parseNativePouRefs(rawNativePous), - }, + return { error: `Library build failed: ${what} resource has no task or instance list.` } } + return { value: value as PLCProjectData } } /** @@ -155,6 +203,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' @@ -176,7 +227,7 @@ import { buildModuleConfigEntries } from '@root/backend/shared/utils/vpp/generat import type { VppPackagePin } from '@root/backend/shared/utils/vpp/vpp-package-pin' import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { getErrorMessage } from '@root/frontend/utils/get-error-message' -import { app as electronApp, dialog } from 'electron' +import { app as electronApp, dialog, MessageChannelMain } from 'electron' import JSZip from 'jszip' import type { ThirdPartyLibraryRequest } from '../../../middleware/shared/ports/compiler-platform-port' @@ -184,6 +235,7 @@ import type { PersistentStorageSettings, PlatformOption } from '../../../middlew import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' import { findVppDeviceByBoardName } from '../../shared/hardware/find-vpp-device' import { persistentStorageSchema } from '../../shared/types/PLC/devices/configuration' +import type { AvailableBoards } from '../hardware/types' import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' @@ -196,6 +248,34 @@ interface MethodsResult { } type HandleOutputDataCallback = (chunk: Buffer | string, logLevel?: 'info' | 'warning' | 'error') => void +/** + * Decode a `MessagePortMain` payload back to a string, handling the + * forms a Node `Buffer` survives V8's structured clone as: + * + * - `string` — passthrough. + * - `Uint8Array` / `ArrayBuffer` — typed-array decode (this is the + * shape Buffers ride as when the channel stays inside the main + * process; `.toString()` on a Uint8Array returns the comma- + * separated number list and was the cause of the `[verify] + * 67,111,109,…` console flood). + * - `{ type: 'Buffer', data: number[] }` — Electron's IPC + * serialisation form, same shape `decodeMessage` in the + * `compiler-adapter` already handles. + * - anything else — `String(...)` fallback. + */ +function decodePortMessage(raw: unknown): string { + if (typeof raw === 'string') return raw + if (raw instanceof Uint8Array) return new TextDecoder().decode(raw) + if (raw instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(raw)) + if (raw && typeof raw === 'object' && 'type' in raw) { + const obj = raw as Record + if (obj.type === 'Buffer' && Array.isArray(obj.data)) { + return new TextDecoder().decode(new Uint8Array(obj.data as number[])) + } + } + return String(raw) +} + type CompileArduinoProgramArgs = { boardTarget: string boardHalsContent: HalsFile[string] @@ -510,6 +590,67 @@ class CompilerModule { return join(electronApp.getAppPath(), 'node_modules', 'strucpp', 'src', 'runtime', 'include') } + /** + * 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. + */ + 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. + * + * 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 #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 } + } + /** * Pull the user's platformOption selections out of a project's * devices/configuration.json. Returns `{}` on any read/parse error — @@ -1386,6 +1527,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 || [] @@ -1399,11 +1546,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 { @@ -1423,6 +1566,12 @@ class CompilerModule { // a 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 || [] @@ -1435,10 +1584,7 @@ class CompilerModule { // Written into /src/, which arduino-cli takes as a library and // compiles with the sketch in the core's own standard — that is what // lets a C++ block resolve its Arduino #includes. - // 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 { @@ -1585,6 +1731,7 @@ class CompilerModule { ...buildArduinoCliCompileArgs(compileEntry, { sketchPath: join(baremetalPath, 'Baremetal.ino'), libraryPath: join(compilationPath, 'src'), + resourceLibraryPaths: await this.#resourceLibraryDirs(compilationPath), avrLibStdCppInclude, cleanBuild, }), @@ -2348,7 +2495,7 @@ class CompilerModule { * name. Missing names (enabled but not installed) come back so * the caller can abort with a clear error before strucpp runs. */ - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives }, ): Promise { _mainProcessPort.start() @@ -2489,6 +2636,7 @@ class CompilerModule { let devicePinMapping: DevicePin[] let libraryArchives: unknown[] let missingLibraries: string[] + let substitutedLibraries: VersionSubstitution[] let avrLibStdCppInclude = '' try { firmwareSkeleton = await this.loadFirmwareSkeletonInMemory(boardRuntime) @@ -2696,10 +2844,10 @@ class CompilerModule { // empty PINMASK_* entries in that case. devicePinMapping = [] } - const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) - const archives = mainProcessBridge.loadEnabledArchives(enabledLibraryNames) + const archives = mainProcessBridge.loadEnabledArchives(projectData.libraries ?? []) libraryArchives = archives.archives missingLibraries = archives.missing + substitutedLibraries = archives.substituted const coreId = typeof boardEntry?.core === 'string' ? boardEntry.core : '' if (coreId.startsWith('arduino:avr')) { avrLibStdCppInclude = await this.ensureAvrLibStdCppCache() @@ -3004,6 +3152,7 @@ class CompilerModule { compileOnly: compileOnly ?? false, libraryArchives, missingLibraries, + substitutedLibraries, firmwareSkeleton, strucppRuntimeHeaders, avrLibStdCppInclude, @@ -3112,7 +3261,7 @@ class CompilerModule { args: Array, _mainProcessPort: CompileProgressChannel, mainProcessBridge: { - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives }, ): Promise { _mainProcessPort.start() @@ -3229,13 +3378,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 { archives: libraries, missing: missingLibraries } = mainProcessBridge.loadEnabledArchives( + projectData.libraries ?? [], + ) + 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) => { @@ -3262,9 +3416,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', @@ -3280,9 +3439,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', @@ -3331,10 +3496,16 @@ 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. 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. * - * The build is target-neutral — no avr-gcc pass, no simulator. - * Running a library is a separate action the renderer drives - * through `compileProgram` with a generated harness project; see + * Running a library is a separate action the renderer drives through + * `compileProgram` with a generated harness project; see * `composeLibraryDebugHarness`. */ async compileLibrary( @@ -3345,8 +3516,8 @@ class CompilerModule { _mainProcessPort.start() // IPC args: `CompileLibraryIpcArgs` — [projectPath, projectData, - // nativePous]. `nativePous` is sent rather than derived here: it has to be - // read off the RAW project data, and by the time anything arrives over + // verifyProjectData, cleanBuild, nativePous]. `nativePous` is sent rather + // than derived here: it has to be read off the RAW project data, and by the time anything arrives over // this channel `preprocessPous` has already lowered every native body to // bridge ST and rewritten its language tag. An older renderer omits it, // which degrades to "this project has no native POUs". @@ -3360,20 +3531,26 @@ class CompilerModule { setTimeout(() => _mainProcessPort.close(), 25) return } - const { projectPath, projectData, nativePous } = parsed.value + const { projectPath, projectData, verifyProjectData, cleanBuild, nativePous } = parsed.value // Bridge the orchestrator's structured port API onto the desktop // platform's existing helpers. This is the only desktop-specific // glue the library build needs — every stage decision lives in // the shared orchestrator from here on. const libraryPort = createDesktopLibraryBuildPort({ - loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), + loadEnabledArchives: (refs) => mainProcessBridge.loadEnabledArchives(refs), + runVerificationCompile: ({ projectPath: p, verifyProjectData: v, target, emit }) => + this.runVerificationCompile(p, v as PLCProjectData, target, mainProcessBridge, (message, logLevel) => + emit(message, logLevel), + ), }) const result = await runLibraryBuildPipeline( { projectPath, projectData, + verifyProjectData, + cleanBuild, nativePous, }, libraryPort, @@ -3385,6 +3562,123 @@ class CompilerModule { // message is delivered before the port closes. setTimeout(() => _mainProcessPort.close(), 25) } + + /** + * Run an end-to-end verification compile of a synthetic Library + * 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. + * + * `forwardLog` is the caller's drain for the inner pipeline's + * message stream. Streaming the strucpp / arduino-cli output is + * the difference between "blank console for 30 seconds while + * arduino-cli compiles" and "user sees progress" — and crucially + * the difference between "the .stlib generated but verification + * failed silently" and "the user knows which C++ line tripped + * avr-gcc". We do keep the first error message internally so the + * summary line at the end of the build is succinct, but every log + * line still flows through. + * + * Resolves with `{success, message?}` either when the inner + * pipeline posts `closePort: true` (happy path) or when its port + * closes without one (the many error paths in `compileProgram`). + * Never throws — matches the caller's "verification is advisory" + * contract. + */ + private async runVerificationCompile( + projectPath: string, + verifyData: PLCProjectData, + target: LibraryVerifyTarget, + bridge: LibraryVerificationBridge, + forwardLog: (message: string, logLevel?: 'info' | 'warning' | 'error') => void, + ): Promise<{ success: boolean; message?: string }> { + 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() + let firstError: string | null = null + let settled = false + + const settle = (result: { success: boolean; message?: string }) => { + if (settled) return + settled = true + try { + channel.port1.close() + } catch { + // Already closed — fine. + } + resolve(result) + } + + channel.port1.on('message', (event) => { + const data = event.data as { + message?: unknown + logLevel?: 'info' | 'warning' | 'error' + closePort?: boolean + } + if (data.message !== undefined) { + // `decodePortMessage` returns readable text from the + // `Uint8Array` Node `Buffer` payloads survive structured + // clone as. Without it, `.toString()` on a Uint8Array + // would render comma-separated byte numbers in the + // console. + const text = decodePortMessage(data.message) + // Forward every line — the caller decides how to render + // them (PLC-build path would prepend `[verify]`). Even + // info-level messages matter here: avr-gcc compile can + // take 10+ seconds on a large library and the user needs + // to see progress. + forwardLog(text, data.logLevel) + // Keep only the FIRST error string for the summary. Once + // arduino-cli or strucpp errors, the cascade usually + // continues with knock-on failures; the first one names + // the underlying cause. + if (data.logLevel === 'error' && firstError === null) { + firstError = text + } + } + if (data.closePort) { + settle(firstError ? { success: false, message: firstError } : { success: true }) + } + }) + // `compileProgram` posts intermediate `closePort: true` messages + // on its happy path but jumps straight to `port.close()` on its + // many error paths, without an explicit close message. Listen + // for the port's 'close' event so an inner-pipeline error can't + // leave the outer library build hanging on an unresolved promise + // — same convention the renderer-side adapter uses. + channel.port1.on('close', () => { + settle(firstError ? { success: false, message: firstError } : { success: true }) + }) + channel.port1.start() + + // The boolean slots (compileOnly / cleanBuild) are runtime + // values the inner `compileProgram` re-casts off `args as [...]`, + const compileArgs: Array = [ + projectPath, + board, + boardCore, + true, + verifyData, + null, + null, + true, + null, + undefined, + ] + void this.compileProgram(compileArgs, channel.port2, bridge).catch((err) => + settle({ success: false, message: getErrorMessage(err) }), + ) + }) + } } export { CompilerModule } diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index 65bdfb486..e2bda954a 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -16,6 +16,7 @@ * shared with the web port impl. */ +import { createHash } from 'node:crypto' import * as fs from 'node:fs/promises' import * as path from 'node:path' @@ -26,7 +27,9 @@ 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' + +import type { EnabledArchives, LibraryRef } from '../../../middleware/shared/ports/library-types' /** * Subset of the desktop CompilerModule that the port leans on. @@ -41,11 +44,31 @@ export interface DesktopLibraryBuildPortDeps { * `missing` — orchestrator fails the build with a Library-Manager- * pointing message before any heavy step runs. */ - loadEnabledArchives(enabledNames: string[]): { archives: unknown[]; missing: string[] } + loadEnabledArchives(refs: ReadonlyArray): EnabledArchives + + /** + * 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 }> } export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps): LibraryBuildPort { return { + computeMd5(input: string): Promise { + // Web's port impl computes the same digest via `spark-md5`. + // The orchestrator's verification cache keys off this value, + // so both platforms MUST agree byte-for-byte. + return Promise.resolve(createHash('md5').update(input).digest('hex')) + }, + transpileToSt( args: TranspileToStArgs, log: (message: string, level: 'info' | 'warning' | 'error') => void, @@ -84,12 +107,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 @@ -102,7 +179,19 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) // Bridge resolves bundled (always-included) + user-installed // archives in one call; names that don't resolve come back // under `missing` for the orchestrator to fail the build on. - return Promise.resolve(deps.loadEnabledArchives(projectLibraryRefs.map((r) => r.name))) + return Promise.resolve(deps.loadEnabledArchives(projectLibraryRefs)) + }, + + 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. + emit: (message, level) => emit(message, level ?? 'info'), + }) }, } } diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index d8664b5e4..bec8e7312 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -56,6 +56,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' @@ -68,6 +70,32 @@ 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 | Uint8Array): Promise { + await fs.mkdir(dirname(absPath), { recursive: true }) + // A VPP plugin payload may be precompiled objects rather than source, so + // bytes are written as bytes. + if (file instanceof Uint8Array) { + await fs.writeFile(absPath, file) + return + } + 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'] @@ -312,11 +340,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 @@ -665,18 +691,9 @@ export function createEditorCompilerPlatformPort( try { const entries = Object.entries(args.bundle) await Promise.all( - entries.map(async ([relPath, content]) => { - const absPath = join(context.sourceTargetFolderPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - // A VPP plugin payload may be precompiled objects rather than - // source, so bytes are written as bytes; the encoding argument - // only applies to the text entries the pipeline composes. - if (typeof content === 'string') { - await fs.writeFile(absPath, content, 'utf-8') - } else { - await fs.writeFile(absPath, content) - } - }), + entries.map(async ([relPath, content]) => + writeBundleFile(join(context.sourceTargetFolderPath, relPath), content), + ), ) return { written: entries.length } } catch (error) { diff --git a/src/backend/editor/library-manager/__tests__/install-from-zip.test.ts b/src/backend/editor/library-manager/__tests__/install-from-zip.test.ts new file mode 100644 index 000000000..909e21583 --- /dev/null +++ b/src/backend/editor/library-manager/__tests__/install-from-zip.test.ts @@ -0,0 +1,242 @@ +/** + * Installing a ZIP of `.stlib` archives. + * + * Same mocks as `library-manager-module.test.ts` — real filesystem under a + * temp dir, Electron and strucpp stubbed — because the point here is what + * lands on disk and in the registry, not what strucpp thinks of an archive. + * + * The case that drove the shape of the result: a bundle where one archive is + * corrupt has to install the rest and say which one it skipped. Refusing the + * whole ZIP over a single bad file is the behaviour this file exists to + * prevent. + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +jest.mock('electron', () => ({ + app: { getPath: () => '/tmp/never-used' }, +})) + +jest.mock( + 'strucpp', + () => ({ + // Malformed input throws, the way the real loader does — that is what + // makes a corrupt entry in the bundle a per-entry failure. + loadStlibFromString: jest.fn((text: string) => JSON.parse(text)), + // The CODESYS path: the importer refuses the marker below so the mixed + // bundle has a failing .library as well as a failing .stlib. + importCodesysLibraryFromBytes: jest.fn(async (bytes: Uint8Array) => { + const text = new TextDecoder().decode(bytes) + if (text.includes('REFUSE')) return { success: false, errors: ['not a CODESYS library'] } + return { success: true, sources: [{ fileName: 'mock.st', source: '(* imported *)' }], globalConstants: {} } + }), + compileStlib: jest.fn((_sources: unknown, options: { name: string; version: string; namespace: string }) => ({ + success: true, + archive: { + manifest: { + name: options.name, + version: options.version, + namespace: options.namespace, + description: 'mock codesys-imported library', + isBuiltin: false, + functions: [], + functionBlocks: [], + types: [], + }, + }, + })), + }), + { virtual: true }, +) + +import JSZip from 'jszip' + +import { LibraryManagerModule } from '../library-manager-module' + +const archiveText = (name: string, version = '1.0.0') => + JSON.stringify({ + manifest: { + name, + version, + namespace: name, + description: `${name} description`, + isBuiltin: false, + functions: [], + functionBlocks: [], + types: [], + }, + }) + +let testRoot: string +let librariesDir: string +let bundledDir: string + +beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), 'library-zip-test-')) + librariesDir = join(testRoot, 'libraries') + bundledDir = join(testRoot, 'bundled') +}) + +afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }) +}) + +const makeModule = () => new LibraryManagerModule({ librariesDir, bundledDir }) + +/** Write a real ZIP to disk and hand back its path, the way the picker would. */ +async function writeZip(files: Record, name = 'bundle.zip'): Promise { + const zip = new JSZip() + for (const [path, content] of Object.entries(files)) zip.file(path, content) + const path = join(testRoot, name) + writeFileSync(path, await zip.generateAsync({ type: 'uint8array' })) + return path +} + +/** Library names the registry holds, sorted. */ +function registryNames(): string[] { + return Object.keys(JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')).libraries).sort() +} + +describe('installing a ZIP of archives', () => { + it('installs every .stlib it holds', async () => { + const zip = await writeZip({ + 'alpha.stlib': archiveText('alpha'), + 'nested/beta.stlib': archiveText('beta'), + 'gamma.stlib': archiveText('gamma', '2.1.0'), + }) + + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(true) + if (!result.success || !('entries' in result)) throw new Error('expected a bundle result') + expect(result.installed).toEqual([ + { name: 'alpha', version: '1.0.0' }, + { name: 'gamma', version: '2.1.0' }, + { name: 'beta', version: '1.0.0' }, + ]) + expect(result.failed).toEqual([]) + expect(registryNames()).toEqual(['alpha', 'beta', 'gamma']) + }) + + it('writes each archive where a single install would have put it', async () => { + const zip = await writeZip({ 'alpha.stlib': archiveText('alpha') }) + await makeModule().installFromFile(zip) + + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + const entry = registry.libraries.alpha.versions['1.0.0'] + expect(entry.origin).toBe('stlib') + expect(readFileSync(entry.stlibPath, 'utf-8')).toBe(archiveText('alpha')) + }) + + it('installs the good ones and names the bad one, rather than refusing the lot', async () => { + const zip = await writeZip({ + 'alpha.stlib': archiveText('alpha'), + 'broken.stlib': 'this is not JSON', + 'gamma.stlib': archiveText('gamma'), + }) + + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(true) + if (!result.success || !('entries' in result)) throw new Error('expected a bundle result') + expect(result.installed.map((library) => library.name)).toEqual(['alpha', 'gamma']) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].path).toBe('broken.stlib') + expect(registryNames()).toEqual(['alpha', 'gamma']) + }) + + it('reports a plain failure when nothing installed', async () => { + // Nothing to select and no reason to refresh, so a partial success here + // would be a lie dressed as one. + const zip = await writeZip({ 'a.stlib': 'nope', 'b.stlib': 'also nope' }) + + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(false) + if (result.success) throw new Error('expected a failure') + expect(result.error).toContain('No libraries installed') + expect(result.error).toContain('a.stlib') + expect(result.error).toContain('b.stlib') + }) + + it('refuses a ZIP holding no library files', async () => { + const zip = await writeZip({ 'readme.txt': 'nothing here' }) + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(false) + if (result.success) throw new Error('expected a failure') + expect(result.error).toContain('no .stlib, .lib or .library files') + }) + + it('refuses a file that is not a ZIP at all', async () => { + const path = join(testRoot, 'fake.zip') + writeFileSync(path, 'definitely not a zip') + + const result = await makeModule().installFromFile(path) + + expect(result.success).toBe(false) + if (result.success) throw new Error('expected a failure') + expect(result.error).toContain('Not a readable ZIP archive') + }) + + it('still refuses a format it has no path for', async () => { + const path = join(testRoot, 'thing.tar') + writeFileSync(path, 'x') + + const result = await makeModule().installFromFile(path) + + expect(result.success).toBe(false) + if (result.success) throw new Error('expected a failure') + expect(result.error).toContain('.zip') + }) + + it('installs CODESYS files in the bundle alongside the archives', async () => { + const zip = await writeZip({ + 'alpha.stlib': archiveText('alpha'), + 'vendor/OSCAT.library': 'codesys bytes', + 'vendor/legacy.lib': 'codesys bytes', + }) + + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(true) + if (!result.success || !('entries' in result)) throw new Error('expected a bundle result') + expect(result.failed).toEqual([]) + // The mock derives each name from the file basename, sanitised. + expect(result.installed.map((library) => library.name).sort()).toEqual(['OSCAT', 'alpha', 'legacy']) + expect(result.entries.map((entry) => entry.success && entry.origin).sort()).toEqual(['codesys', 'codesys', 'stlib']) + expect(registryNames()).toEqual(['OSCAT', 'alpha', 'legacy']) + }) + + it('reports a CODESYS file that fails to import without touching the rest', async () => { + const zip = await writeZip({ + 'alpha.stlib': archiveText('alpha'), + 'bad.library': 'REFUSE this one', + }) + + const result = await makeModule().installFromFile(zip) + + expect(result.success).toBe(true) + if (!result.success || !('entries' in result)) throw new Error('expected a bundle result') + expect(result.installed.map((library) => library.name)).toEqual(['alpha']) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].path).toBe('bad.library') + expect(result.failed[0].error).toContain('not a CODESYS library') + }) + + it('lands every version when the bundle carries several of one library', async () => { + // The reason a bundle exists at all in some vendor drops: one library, + // built for several releases, meant to sit side by side. + const zip = await writeZip({ + 'alpha-1.stlib': archiveText('alpha', '1.0.0'), + 'alpha-2.stlib': archiveText('alpha', '2.0.0'), + }) + + await makeModule().installFromFile(zip) + + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + expect(Object.keys(registry.libraries.alpha.versions).sort()).toEqual(['1.0.0', '2.0.0']) + }) +}) diff --git a/src/backend/editor/library-manager/__tests__/library-manager-module.test.ts b/src/backend/editor/library-manager/__tests__/library-manager-module.test.ts index 2064833fd..a4da64bef 100644 --- a/src/backend/editor/library-manager/__tests__/library-manager-module.test.ts +++ b/src/backend/editor/library-manager/__tests__/library-manager-module.test.ts @@ -11,7 +11,7 @@ * the real (ESM) strucpp package. */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -61,6 +61,13 @@ jest.mock( import type { PublicLibrary } from '../../../../middleware/shared/ports/public-catalog-types' import { LibraryManagerModule } from '../library-manager-module' +/** Where the module actually put a library, read back from the registry. */ +function installedPath(librariesDir: string, name: string): string { + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + const versions = (registry.libraries[name]?.versions ?? {}) as Record + return Object.values(versions)[0]?.stlibPath ?? '' +} + function makeArchive(name: string, version = '1.0.0') { return { manifest: { @@ -201,14 +208,14 @@ describe('LibraryManagerModule', () => { const result = await mod.installFromFile(tmp) expect(result).toEqual({ success: true, name: 'my-lib', version: '0.1.0', origin: 'stlib' }) - // Archive lives under {librariesDir}//.stlib - const stlibPath = join(librariesDir, 'my-lib', 'my-lib.stlib') + // Archive lives under {librariesDir}///.stlib + const stlibPath = join(librariesDir, 'my-lib', '0.1.0', 'my-lib.stlib') expect(existsSync(stlibPath)).toBe(true) - // Registry has a row keyed by name with the right metadata. + // Registry has a row keyed by name, holding that version. const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) - expect(registry.libraries['my-lib']).toMatchObject({ - version: '0.1.0', + expect(registry.formatVersion).toBe('2.0') + expect(registry.libraries['my-lib'].versions['0.1.0']).toMatchObject({ origin: 'stlib', stlibPath, }) @@ -244,9 +251,10 @@ describe('LibraryManagerModule', () => { const result = await mod.installFromFile(tmp) expect(result).toMatchObject({ success: true, origin: 'codesys' }) // Mock derives the name from the file basename, sanitised. - if (result.success && !('canceled' in result && result.canceled)) { + // Not a ZIP, so not the bundle arm -- narrow it away to read `name`. + if (result.success && !('entries' in result) && !result.canceled) { expect(result.name).toBe('OSCAT') - expect(existsSync(join(librariesDir, 'OSCAT', 'OSCAT.stlib'))).toBe(true) + expect(existsSync(installedPath(librariesDir, 'OSCAT'))).toBe(true) } }) @@ -314,7 +322,7 @@ describe('LibraryManagerModule', () => { // Simulate disk corruption: the file is gone but the registry // still records it. loadAll should silently skip rather than // throw or return undefined entries. - rmSync(join(librariesDir, 'foo', 'foo.stlib')) + rmSync(join(librariesDir, 'foo', '1.0.0', 'foo.stlib')) expect(mod.loadAll()).toEqual([]) }) }) @@ -342,7 +350,7 @@ describe('LibraryManagerModule', () => { writeFileSync(tmp, JSON.stringify(makeArchive('oscat-basic')), 'utf-8') await mod.installFromFile(tmp) - const result = mod.loadEnabledArchives(['oscat-basic']) + const result = mod.loadEnabledArchives([{ name: 'oscat-basic' }]) expect(result.archives.map((a) => a.manifest.name)).toEqual(['iec-standard-fb', 'oscat-basic']) expect(result.missing).toEqual([]) }) @@ -365,9 +373,9 @@ describe('LibraryManagerModule', () => { await mod.installFromFile(tmp) // Wipe the archive but keep the registry entry — pre-compile // gate should detect this as missing. - rmSync(join(librariesDir, 'foo', 'foo.stlib')) + rmSync(join(librariesDir, 'foo', '1.0.0', 'foo.stlib')) - const result = mod.loadEnabledArchives(['foo', 'phantom']) + const result = mod.loadEnabledArchives([{ name: 'foo' }, { name: 'phantom' }]) expect(result.archives).toEqual([]) expect(result.missing).toEqual(['foo', 'phantom']) }) @@ -427,8 +435,8 @@ describe('LibraryManagerModule', () => { ]) // Both files actually land on disk in the user-installed // shape so subsequent listInstalled() picks them up. - expect(existsSync(join(librariesDir, 'alpha-lib', 'alpha-lib.stlib'))).toBe(true) - expect(existsSync(join(librariesDir, 'beta-lib', 'beta-lib.stlib'))).toBe(true) + expect(existsSync(installedPath(librariesDir, 'alpha-lib'))).toBe(true) + expect(existsSync(installedPath(librariesDir, 'beta-lib'))).toBe(true) expect(mod.listInstalled().map((r) => r.name)).toEqual(['alpha-lib', 'beta-lib']) }) @@ -452,7 +460,7 @@ describe('LibraryManagerModule', () => { const installed = mod.listInstalled().find((l) => l.name === 'alpha-lib') expect(installed).toMatchObject({ displayName: 'ACME Industrial', description: 'Catalog description' }) - const persistedArchive = JSON.parse(readFileSync(join(librariesDir, 'alpha-lib', 'alpha-lib.stlib'), 'utf-8')) + const persistedArchive = JSON.parse(readFileSync(installedPath(librariesDir, 'alpha-lib'), 'utf-8')) expect(persistedArchive.manifest.displayName).toBe('ACME Industrial') expect(persistedArchive.manifest.description).toBe('Catalog description') }) @@ -471,7 +479,7 @@ describe('LibraryManagerModule', () => { }), ]) - const persistedText = readFileSync(join(librariesDir, 'alpha-lib', 'alpha-lib.stlib'), 'utf-8') + const persistedText = readFileSync(installedPath(librariesDir, 'alpha-lib'), 'utf-8') expect(JSON.parse(persistedText)).toEqual(archive) }) @@ -499,7 +507,7 @@ describe('LibraryManagerModule', () => { name: 'good-lib', }) // The good one still made it to disk. - expect(existsSync(join(librariesDir, 'good-lib', 'good-lib.stlib'))).toBe(true) + expect(existsSync(installedPath(librariesDir, 'good-lib'))).toBe(true) }) it('returns an empty batch when called with no ids', async () => { @@ -530,4 +538,171 @@ describe('LibraryManagerModule', () => { }) }) }) + describe('versions side by side', () => { + /** Install `name` at `version` through the normal file path. */ + async function install(mod: LibraryManagerModule, name: string, version: string) { + const tmp = join(testRoot, `${name}-${version}.stlib`) + writeFileSync(tmp, JSON.stringify(makeArchive(name, version)), 'utf-8') + return mod.installFromFile(tmp) + } + + it('keeps both versions on disk and in the registry', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + + expect(existsSync(join(librariesDir, 'node-uio', '0.0.1', 'node-uio.stlib'))).toBe(true) + expect(existsSync(join(librariesDir, 'node-uio', '0.0.2', 'node-uio.stlib'))).toBe(true) + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + expect(Object.keys(registry.libraries['node-uio'].versions).sort()).toEqual(['0.0.1', '0.0.2']) + }) + + it('reports the newest version and lists the rest', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + + expect(mod.listInstalled()).toEqual([ + expect.objectContaining({ name: 'node-uio', version: '0.0.2', versions: ['0.0.2', '0.0.1'] }), + ]) + }) + + it('resolves the version a project pins', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + + const result = mod.loadEnabledArchives([{ name: 'node-uio', version: '0.0.1' }]) + expect(result.archives.map((a) => a.manifest.version)).toEqual(['0.0.1']) + expect(result.substituted).toEqual([]) + }) + + it('falls back to the newest and reports the substitution', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + + const result = mod.loadEnabledArchives([{ name: 'node-uio', version: '0.1.0' }]) + expect(result.archives.map((a) => a.manifest.version)).toEqual(['0.0.1']) + expect(result.missing).toEqual([]) + expect(result.substituted).toEqual([{ name: 'node-uio', wanted: '0.1.0', used: '0.0.1' }]) + }) + + it('uninstalls one version and leaves the other', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + + expect(mod.uninstall('node-uio', '0.0.1')).toEqual({ success: true }) + expect(existsSync(join(librariesDir, 'node-uio', '0.0.1'))).toBe(false) + expect(existsSync(join(librariesDir, 'node-uio', '0.0.2', 'node-uio.stlib'))).toBe(true) + expect(mod.listInstalled()).toEqual([expect.objectContaining({ version: '0.0.2', versions: ['0.0.2'] })]) + }) + + it('refuses to uninstall a version that is not installed', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + expect(mod.uninstall('node-uio', '9.9.9')).toEqual({ + success: false, + error: "Library 'node-uio' version 9.9.9 is not installed", + }) + }) + + it('installs a version whose string is not a legal path segment', async () => { + // Semver build metadata is legal and its '+' is not a path character. + const mod = makeModule() + await install(mod, 'node-uio', '1.0.0') + const tmp = join(testRoot, 'build-meta.stlib') + writeFileSync(tmp, JSON.stringify(makeArchive('node-uio', '1.0.0+sha.abc')), 'utf-8') + + expect(await mod.installFromFile(tmp)).toMatchObject({ success: true, version: '1.0.0+sha.abc' }) + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + // The real version is the key; the folder is only sanitised. + expect(Object.keys(registry.libraries['node-uio'].versions).sort()).toEqual(['1.0.0', '1.0.0+sha.abc']) + expect(mod.loadEnabledArchives([{ name: 'node-uio', version: '1.0.0+sha.abc' }]).archives).toHaveLength(1) + }) + + it('uninstalls a version whose string is not a legal path segment', async () => { + // Regression: uninstall built its folder from the raw version and ran it + // through validatePathId, so a version that installs fine as + // '1.0.0+sha.abc' could never be removed. The folder comes from the + // registry entry instead. + const mod = makeModule() + await install(mod, 'node-uio', '1.0.0') + const tmp = join(testRoot, 'build-meta.stlib') + writeFileSync(tmp, JSON.stringify(makeArchive('node-uio', '1.0.0+sha.abc')), 'utf-8') + await mod.installFromFile(tmp) + + expect(mod.uninstall('node-uio', '1.0.0+sha.abc')).toEqual({ success: true }) + + const registry = JSON.parse(readFileSync(join(librariesDir, 'registry.json'), 'utf-8')) + expect(Object.keys(registry.libraries['node-uio'].versions)).toEqual(['1.0.0']) + expect(mod.listInstalled()).toEqual([expect.objectContaining({ versions: ['1.0.0'] })]) + }) + + it('takes the library folder with the last version', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + + expect(mod.uninstall('node-uio', '0.0.1')).toEqual({ success: true }) + + expect(existsSync(join(librariesDir, 'node-uio'))).toBe(false) + expect(mod.listInstalled()).toEqual([]) + }) + + it('leaves the other versions alone when one is removed', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + + expect(mod.uninstall('node-uio', '0.0.2')).toEqual({ success: true }) + + expect(existsSync(join(librariesDir, 'node-uio', '0.0.1', 'node-uio.stlib'))).toBe(true) + expect(mod.listInstalled()).toEqual([expect.objectContaining({ version: '0.0.1', versions: ['0.0.1'] })]) + }) + + it('re-installing a version reuses its folder rather than making another', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '1.0.0') + const first = installedPath(librariesDir, 'node-uio') + await install(mod, 'node-uio', '1.0.0') + + expect(installedPath(librariesDir, 'node-uio')).toBe(first) + expect(mod.listInstalled()).toEqual([expect.objectContaining({ versions: ['1.0.0'] })]) + }) + + it('lists the versions still on disk when the newest archive is gone', async () => { + const mod = makeModule() + await install(mod, 'node-uio', '0.0.1') + await install(mod, 'node-uio', '0.0.2') + rmSync(join(librariesDir, 'node-uio', '0.0.2'), { recursive: true }) + + expect(mod.listInstalled()).toEqual([ + expect.objectContaining({ name: 'node-uio', version: '0.0.1', versions: ['0.0.1'] }), + ]) + }) + + it('still resolves a library recorded by the old single-version registry', () => { + // The v1 layout: one archive directly under the library directory. + const legacyPath = join(librariesDir, 'legacy-lib', 'legacy-lib.stlib') + mkdirSync(join(librariesDir, 'legacy-lib'), { recursive: true }) + writeFileSync(legacyPath, JSON.stringify(makeArchive('legacy-lib', '1.2.3')), 'utf-8') + writeFileSync( + join(librariesDir, 'registry.json'), + JSON.stringify({ + formatVersion: '1.0', + libraries: { + 'legacy-lib': { version: '1.2.3', installedAt: '', stlibPath: legacyPath, origin: 'stlib' }, + }, + }), + 'utf-8', + ) + + const mod = makeModule() + expect(mod.listInstalled()).toEqual([ + expect.objectContaining({ name: 'legacy-lib', version: '1.2.3', versions: ['1.2.3'] }), + ]) + const result = mod.loadEnabledArchives([{ name: 'legacy-lib', version: '1.2.3' }]) + expect(result.archives.map((a) => a.manifest.name)).toEqual(['legacy-lib']) + }) + }) }) diff --git a/src/backend/editor/library-manager/__tests__/registry-versions.test.ts b/src/backend/editor/library-manager/__tests__/registry-versions.test.ts new file mode 100644 index 000000000..cefbf2d67 --- /dev/null +++ b/src/backend/editor/library-manager/__tests__/registry-versions.test.ts @@ -0,0 +1,80 @@ +import { REGISTRY_FORMAT_VERSION, migrateRegistry, resolveVersion, versionsNewestFirst } from '../registry-versions' +import type { LibraryVersionEntry } from '../types' + +const entry = (stlibPath: string): LibraryVersionEntry => ({ + installedAt: '2026-01-01T00:00:00.000Z', + stlibPath, + origin: 'stlib', +}) + +describe('migrateRegistry', () => { + it('lifts a v1 row into a versions map without moving the file', () => { + const migrated = migrateRegistry({ + formatVersion: '1.0', + libraries: { + 'node-uio': { + version: '0.0.1', + installedAt: '2026-09-08T04:18:21.694Z', + stlibPath: '/libs/node-uio/node-uio.stlib', + origin: 'stlib', + }, + }, + }) + + expect(migrated.formatVersion).toBe(REGISTRY_FORMAT_VERSION) + expect(migrated.libraries['node-uio'].versions).toEqual({ + '0.0.1': { + installedAt: '2026-09-08T04:18:21.694Z', + // The path it already had: migration touches no files. + stlibPath: '/libs/node-uio/node-uio.stlib', + origin: 'stlib', + }, + }) + }) + + it('passes a v2 registry through unchanged', () => { + const v2 = { formatVersion: '2.0', libraries: { foo: { versions: { '1.0.0': entry('/libs/a') } } } } + expect(migrateRegistry(v2).libraries.foo.versions['1.0.0']).toEqual(entry('/libs/a')) + }) + + it('drops rows that are neither shape rather than throwing', () => { + const migrated = migrateRegistry({ + formatVersion: '1.0', + libraries: { good: { version: '1.0.0', installedAt: '', stlibPath: '/a', origin: 'stlib' }, bad: 'nonsense' }, + }) + expect(Object.keys(migrated.libraries)).toEqual(['good']) + }) + + it('treats anything that is not a registry as empty', () => { + expect(migrateRegistry(null).libraries).toEqual({}) + expect(migrateRegistry('{}').libraries).toEqual({}) + expect(migrateRegistry({ libraries: 7 }).libraries).toEqual({}) + }) +}) + +describe('versionsNewestFirst', () => { + it('orders by semver, not lexically', () => { + const versions = { '0.9.0': entry('/a'), '0.10.0': entry('/b'), '1.0.0': entry('/c') } + expect(versionsNewestFirst(versions)).toEqual(['1.0.0', '0.10.0', '0.9.0']) + }) +}) + +describe('resolveVersion', () => { + const versions = { '0.0.1': entry('/one'), '0.0.2': entry('/two') } + + it('returns the exact version when it is installed', () => { + expect(resolveVersion(versions, '0.0.1')).toEqual({ version: '0.0.1', entry: entry('/one'), substituted: false }) + }) + + it('falls back to the newest and flags the substitution', () => { + expect(resolveVersion(versions, '0.1.0')).toEqual({ version: '0.0.2', entry: entry('/two'), substituted: true }) + }) + + it('returns the newest unflagged when no version is asked for', () => { + expect(resolveVersion(versions)).toEqual({ version: '0.0.2', entry: entry('/two'), substituted: false }) + }) + + it('returns null when nothing is installed', () => { + expect(resolveVersion({}, '1.0.0')).toBeNull() + }) +}) diff --git a/src/backend/editor/library-manager/library-manager-module.ts b/src/backend/editor/library-manager/library-manager-module.ts index 3cc1ba12d..01e21622e 100644 --- a/src/backend/editor/library-manager/library-manager-module.ts +++ b/src/backend/editor/library-manager/library-manager-module.ts @@ -1,10 +1,18 @@ import { app } from 'electron' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { basename, extname, join } from 'path' +import { basename, dirname, extname, join } from 'path' import type { CatalogTransportPort } from '../../../middleware/shared/ports/catalog-transport-port' import type { StlibArchiveDTO } from '../../../middleware/shared/ports/library-port' -import type { InstalledLibrary, LibraryInstallResult } from '../../../middleware/shared/ports/library-types' +import type { + EnabledArchives, + InstalledLibrary, + LibraryBundleEntry, + LibraryInstallResult, + LibraryRef, + LibrarySingleInstallResult, + VersionSubstitution, +} from '../../../middleware/shared/ports/library-types' import type { PublicLibrary } from '../../../middleware/shared/ports/public-catalog-types' import { bundledArchiveToInstalledRow, userArchiveToInstalledRow } from '../../shared/library/installed-library-rows' import { @@ -13,9 +21,17 @@ import { prepareStlibUpload, } from '../../shared/library/prepare-library-upload' import { downloadPublicLibrary } from '../../shared/library/public-catalog-client' +import { type BundledLibraryFile, readLibraryBundle } from '../../shared/library/read-library-bundle' import { validatePathId } from '../../shared/utils/path-safety' import { assertPathContained } from '../utils/path-containment' import { createDesktopCatalogTransport } from './desktop-catalog-transport' +import { + migrateRegistry, + REGISTRY_FORMAT_VERSION, + resolveVersion, + versionDirName, + versionsNewestFirst, +} from './registry-versions' import type { LibraryRegistry } from './types' /** @@ -144,9 +160,12 @@ export class LibraryManagerModule { if (ext === '.lib' || ext === '.library') { return this.installFromCodesys(filePath) } + if (ext === '.zip') { + return this.installFromZip(filePath) + } return { success: false, - error: `Unsupported library format: ${ext} (expected .stlib, .lib, or .library)`, + error: `Unsupported library format: ${ext} (expected .stlib, .lib, .library, or .zip)`, } } catch (err) { return { success: false, error: `Install failed: ${err instanceof Error ? err.message : String(err)}` } @@ -168,16 +187,27 @@ export class LibraryManagerModule { const registry = this.readRegistry() const userEntries = Object.entries(registry.libraries).sort(([a], [b]) => a.localeCompare(b)) for (const [name, info] of userEntries) { - const archive = this.readUserArchive(name, info.stlibPath) + // One row per library, not per version: the row names the newest and + // lists the rest, so the manager can offer them without duplicate rows. + // Only versions whose archive is actually readable are listed -- and a + // missing newest must not hide the ones that are still here. + const readable = versionsNewestFirst(info.versions).filter( + (version) => this.readUserArchive(name, info.versions[version].stlibPath) !== null, + ) + const newest = readable[0] + if (!newest) continue + const entry = info.versions[newest] + const archive = this.readUserArchive(name, entry.stlibPath) if (!archive) continue - out.push( - userArchiveToInstalledRow(archive, { + out.push({ + ...userArchiveToInstalledRow(archive, { name, - version: info.version, - installedAt: info.installedAt, - origin: info.origin, + version: newest, + installedAt: entry.installedAt, + origin: entry.origin, }), - ) + versions: readable, + }) } return out } @@ -196,25 +226,33 @@ export class LibraryManagerModule { * surface a single "install or remove" error before strucpp runs, * instead of strucpp's per-symbol "function not found" cascade. */ - loadEnabledArchives(enabledNames: string[]): { archives: StlibArchiveDTO[]; missing: string[] } { + loadEnabledArchives(refs: ReadonlyArray): EnabledArchives { const archives: StlibArchiveDTO[] = [] for (const archive of this.readBundledArchives()) archives.push(archive) const registry = this.readRegistry() const missing: string[] = [] - for (const name of enabledNames) { - const entry = registry.libraries[name] - if (!entry) { - missing.push(name) + const substituted: VersionSubstitution[] = [] + for (const ref of refs) { + const installed = registry.libraries[ref.name] + const resolved = installed ? resolveVersion(installed.versions, ref.version) : null + if (!resolved) { + missing.push(ref.name) continue } - const archive = this.readUserArchive(name, entry.stlibPath) + const archive = this.readUserArchive(ref.name, resolved.entry.stlibPath) if (!archive) { - missing.push(name) + missing.push(ref.name) continue } + // Reported rather than fatal: refusing to build a project whose pinned + // version is not on this machine would strand it, and the substitution + // is exactly what the caller needs to tell the user about. + if (resolved.substituted && ref.version) { + substituted.push({ name: ref.name, wanted: ref.version, used: resolved.version }) + } archives.push(archive) } - return { archives, missing } + return { archives, missing, substituted } } /** @@ -230,12 +268,14 @@ export class LibraryManagerModule { * User-installed wins over bundled, matching `loadAll`'s precedence: a * library someone installed deliberately is the one the project means. */ - readArchiveText(name: string): string | null { - const entry = this.readRegistry().libraries[name] - if (entry) { + readArchiveText(name: string, version?: string): string | null { + const installed = this.readRegistry().libraries[name] + const resolved = installed ? resolveVersion(installed.versions, version) : null + if (resolved) { + const { stlibPath } = resolved.entry try { - assertPathContained(this.librariesDir, entry.stlibPath, `library[${name}].stlibPath`) - if (existsSync(entry.stlibPath)) return readFileSync(entry.stlibPath, 'utf-8') + assertPathContained(this.librariesDir, stlibPath, `library[${name}].stlibPath`) + if (existsSync(stlibPath)) return readFileSync(stlibPath, 'utf-8') } catch { // Fall through to the bundled copy. A registry entry pointing outside // the libraries directory is exactly what that guard exists to stop. @@ -267,8 +307,13 @@ export class LibraryManagerModule { const registry = this.readRegistry() const userEntries = Object.entries(registry.libraries).sort(([a], [b]) => a.localeCompare(b)) for (const [name, info] of userEntries) { - const archive = this.readUserArchive(name, info.stlibPath) - if (archive) out.push(archive) + // Every installed version, newest first. The renderer narrows this to + // the one the open project pins; the pool itself carries them all so + // that choice can be made without another round trip. + for (const version of versionsNewestFirst(info.versions)) { + const archive = this.readUserArchive(name, info.versions[version].stlibPath) + if (archive) out.push(archive) + } } return out } @@ -358,7 +403,7 @@ export class LibraryManagerModule { * — those are always-on (the caller should disable them at the * project level instead). */ - uninstall(name: string): { success: boolean; error?: string } { + uninstall(name: string, version?: string): { success: boolean; error?: string } { try { validatePathId(name, 'name') if (this.isBundled(name)) { @@ -366,13 +411,41 @@ export class LibraryManagerModule { } const registry = this.readRegistry() - const entry = registry.libraries[name] - if (!entry) { + const installed = registry.libraries[name] + if (!installed) { return { success: false, error: `Library '${name}' is not installed` } } const libraryDir = join(this.librariesDir, name) assertPathContained(this.librariesDir, libraryDir, 'library install path') + + // One version, or the whole library when none is named. + if (version !== undefined) { + const entry = installed.versions[version] + if (!entry) { + return { success: false, error: `Library '${name}' version ${version} is not installed` } + } + // The folder comes from the entry, never from the version string. + // `persistPrepared` sanitises a version into a folder name, so a + // version legally installed as `1.0.0+sha.abc` does not name its own + // directory and cannot be validated as a path id. + const entryDir = dirname(entry.stlibPath) + // A version migrated from the old layout keeps its archive directly in + // the library folder, shared with its siblings — remove just the archive. + const target = entryDir === libraryDir ? entry.stlibPath : entryDir + assertPathContained(this.librariesDir, target, 'library install path') + if (existsSync(target)) rmSync(target, { recursive: true }) + delete installed.versions[version] + if (Object.keys(installed.versions).length === 0) { + delete registry.libraries[name] + // Last version gone: take the library folder with it rather than + // leaving an empty directory behind. + if (existsSync(libraryDir)) rmSync(libraryDir, { recursive: true }) + } + this.writeRegistry(registry) + return { success: true } + } + if (existsSync(libraryDir)) { rmSync(libraryDir, { recursive: true }) } @@ -400,7 +473,7 @@ export class LibraryManagerModule { * The text is validated by the same strucpp preparer as any other install, * so an archive from a device gets no more trust than one a user picked. */ - async installFromText(archiveText: string): Promise { + async installFromText(archiveText: string): Promise { let prepared: PreparedLibrary try { prepared = prepareStlibUpload(archiveText) @@ -410,7 +483,58 @@ export class LibraryManagerModule { return this.persistPrepared(prepared) } - private async installStlib(filePath: string): Promise { + /** + * Install every library file a ZIP holds -- `.stlib` archives and CODESYS + * `.lib`/`.library` files alike, each through its own preparer. + * + * Each archive is installed on its own, and one failing leaves the rest + * alone: a bundle of eight where one is corrupt should install seven and say + * which one it skipped, not refuse the lot. A ZIP that will not open, or + * holds no archive at all, is a different thing -- the user picked the wrong + * file -- and fails outright. + */ + private async installFromZip(filePath: string): Promise { + let bundled: BundledLibraryFile[] + try { + bundled = await readLibraryBundle(new Uint8Array(readFileSync(filePath))) + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + + const entries: LibraryBundleEntry[] = [] + for (const file of bundled) { + const result = + file.kind === 'stlib' + ? await this.installFromText(file.text) + : await this.installFromCodesysBytes(file.bytes, file.filename) + entries.push( + result.success && !result.canceled + ? { + path: file.path, + success: true, + name: result.name, + version: result.version, + origin: result.origin, + } + : { path: file.path, success: false, error: result.success ? 'Nothing installed' : result.error }, + ) + } + + const installed = entries.filter((entry) => entry.success).map(({ name, version }) => ({ name, version })) + const failed = entries.filter((entry) => !entry.success).map(({ path, error }) => ({ path, error })) + + // Nothing landed: the caller has no library to select and no reason to + // refresh, so this reads as a plain failure rather than a partial success. + if (installed.length === 0) { + return { + success: false, + error: `No libraries installed from the ZIP. ${failed.map((f) => `${f.path}: ${f.error}`).join('; ')}`, + } + } + return { success: true, entries, installed, failed } + } + + private async installStlib(filePath: string): Promise { let prepared: PreparedLibrary try { prepared = prepareStlibUpload(readFileSync(filePath, 'utf-8')) @@ -420,15 +544,26 @@ export class LibraryManagerModule { return this.persistPrepared(prepared) } - private async installFromCodesys(filePath: string): Promise { + private async installFromCodesys(filePath: string): Promise { // Read the .lib/.library bytes here (Node-only territory) and // hand them to the platform-agnostic shared preparer. The // bytes-in / filename-in shape is the same web's library-adapter // uses against an HTTP upload, so the shared module isn't coupled // to either backend's storage. + return this.installFromCodesysBytes(new Uint8Array(readFileSync(filePath)), basename(filePath)) + } + + /** + * Install a CODESYS library from bytes rather than a file on disk. + * + * A `.lib` inside a ZIP has no path of its own, and writing it out just to + * read it back would add a failure mode for nothing. `filename` still + * matters: the preparer derives the library identifier from it. + */ + async installFromCodesysBytes(bytes: Uint8Array, filename: string): Promise { let prepared: PreparedLibrary try { - prepared = await prepareCodesysUpload(new Uint8Array(readFileSync(filePath)), basename(filePath)) + prepared = await prepareCodesysUpload(bytes, filename) } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) } } @@ -441,7 +576,7 @@ export class LibraryManagerModule { * Manifest parsing + extraction already happened in the shared * preparer; this step is pure storage + filesystem-safety checks. */ - private persistPrepared(prepared: PreparedLibrary): LibraryInstallResult { + private persistPrepared(prepared: PreparedLibrary): LibrarySingleInstallResult { try { validatePathId(prepared.name, 'manifest.name') } catch (err) { @@ -455,15 +590,26 @@ export class LibraryManagerModule { } } - const libraryDir = join(this.librariesDir, prepared.name) - assertPathContained(this.librariesDir, libraryDir, 'library install path') - mkdirSync(libraryDir, { recursive: true }) - const stlibPath = join(libraryDir, `${prepared.name}.stlib`) + const registry = this.readRegistry() + // Versions live side by side; installing one leaves the others alone, and + // re-installing the same one replaces only itself -- so it keeps the folder + // it already has rather than being given a fresh one. + const installed = (registry.libraries[prepared.name] ??= { versions: {} }) + const existing = installed.versions[prepared.version] + const folder = existing + ? basename(dirname(existing.stlibPath)) + : versionDirName( + prepared.version, + Object.values(installed.versions).map((entry) => basename(dirname(entry.stlibPath))), + ) + + const versionDir = join(this.librariesDir, prepared.name, folder) + assertPathContained(this.librariesDir, versionDir, 'library install path') + mkdirSync(versionDir, { recursive: true }) + const stlibPath = join(versionDir, `${prepared.name}.stlib`) writeFileSync(stlibPath, prepared.archive, 'utf-8') - const registry = this.readRegistry() - registry.libraries[prepared.name] = { - version: prepared.version, + installed.versions[prepared.version] = { installedAt: new Date().toISOString(), stlibPath, origin: prepared.origin, @@ -552,14 +698,15 @@ export class LibraryManagerModule { return raw as StlibArchiveDTO } + /** Always returned in the current format; a v1 file is migrated on read. */ private readRegistry(): LibraryRegistry { if (!existsSync(this.registryPath)) { - return { formatVersion: '1.0', libraries: {} } + return { formatVersion: REGISTRY_FORMAT_VERSION, libraries: {} } } try { - return JSON.parse(readFileSync(this.registryPath, 'utf-8')) as LibraryRegistry + return migrateRegistry(JSON.parse(readFileSync(this.registryPath, 'utf-8'))) } catch { - return { formatVersion: '1.0', libraries: {} } + return { formatVersion: REGISTRY_FORMAT_VERSION, libraries: {} } } } diff --git a/src/backend/editor/library-manager/registry-versions.ts b/src/backend/editor/library-manager/registry-versions.ts new file mode 100644 index 000000000..1584e8c20 --- /dev/null +++ b/src/backend/editor/library-manager/registry-versions.ts @@ -0,0 +1,112 @@ +/** + * Registry format handling: migrate the v1 shape, and pick a version. + * + * Split out of the module so both are testable without an Electron app, and + * so the one place that decides "which installed version does this project + * get" is not buried in a filesystem method. + */ + +import { compareSemver } from '../../../frontend/utils/semver' +import type { LibraryRegistry, LibraryVersionEntry } from './types' + +export const REGISTRY_FORMAT_VERSION = '2.0' + +const emptyRegistry = (): LibraryRegistry => ({ formatVersion: REGISTRY_FORMAT_VERSION, libraries: {} }) + +type UnknownRecord = Record + +const isRecord = (value: unknown): value is UnknownRecord => !!value && typeof value === 'object' + +/** A v1 row carries its single version inline, beside the path. */ +function asV1Entry(value: unknown): ({ version: string } & LibraryVersionEntry) | null { + if (!isRecord(value)) return null + if (typeof value.version !== 'string' || typeof value.stlibPath !== 'string') return null + return value as unknown as { version: string } & LibraryVersionEntry +} + +function asVersionMap(value: unknown): Record | null { + if (!isRecord(value) || !isRecord(value.versions)) return null + const out: Record = {} + for (const [version, entry] of Object.entries(value.versions)) { + if (isRecord(entry) && typeof entry.stlibPath === 'string') out[version] = entry as unknown as LibraryVersionEntry + } + return out +} + +/** + * Return any registry on disk in v2 shape. + * + * A v1 row keeps the path it already has rather than being moved, so the + * migration touches no files and cannot leave the store half-converted. + */ +export function migrateRegistry(raw: unknown): LibraryRegistry { + if (!isRecord(raw) || !isRecord(raw.libraries)) return emptyRegistry() + + const libraries: LibraryRegistry['libraries'] = {} + for (const [name, value] of Object.entries(raw.libraries)) { + const versions = asVersionMap(value) + if (versions) { + if (Object.keys(versions).length > 0) libraries[name] = { versions } + continue + } + const v1 = asV1Entry(value) + if (!v1) continue + libraries[name] = { + versions: { + [v1.version]: { + installedAt: v1.installedAt, + stlibPath: v1.stlibPath, + origin: v1.origin, + }, + }, + } + } + return { formatVersion: REGISTRY_FORMAT_VERSION, libraries } +} + +/** Installed versions, newest first. */ +export function versionsNewestFirst(versions: Record): string[] { + return Object.keys(versions).sort((a, b) => compareSemver(b, a)) +} + +export interface ResolvedVersion { + version: string + entry: LibraryVersionEntry + /** The wanted version is not installed, so this is the newest instead. */ + substituted: boolean +} + +/** + * Pick the version a caller gets. + * + * An exact match wins. Otherwise the newest installed is returned and flagged + * as substituted, so the caller reports the mismatch rather than a project + * silently building against a version it does not name. + */ +export function resolveVersion(versions: Record, wanted?: string): ResolvedVersion | null { + if (wanted && versions[wanted]) return { version: wanted, entry: versions[wanted], substituted: false } + const newest = versionsNewestFirst(versions)[0] + if (!newest) return null + return { version: newest, entry: versions[newest], substituted: wanted !== undefined } +} + +/** + * A directory name to keep one version's archive in. + * + * The registry records the real version string and the real path, so the + * folder only has to be safe -- not equal to the version. That matters + * because a manifest version is free text: semver build metadata + * (`1.0.0+sha.abc`) is legal and its `+` is not a permitted path character, + * and refusing to install such a library would be a regression. + * + * @param taken directory names already used by this library's other versions. + */ +export function versionDirName(version: string, taken: Iterable): string { + const used = new Set(taken) + const base = version.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '_') + if (base.length === 0) return `v_${used.size + 1}` + if (!used.has(base)) return base + let suffix = 2 + while (used.has(`${base}_${suffix}`)) suffix += 1 + return `${base}_${suffix}` +} diff --git a/src/backend/editor/library-manager/types.ts b/src/backend/editor/library-manager/types.ts index a795ac612..aea297fc7 100644 --- a/src/backend/editor/library-manager/types.ts +++ b/src/backend/editor/library-manager/types.ts @@ -11,6 +11,17 @@ import type { InstalledLibrary, LibraryInstallResult } from '../../../middleware/shared/ports/library-types' +/** One installed build of one library version. */ +export type LibraryVersionEntry = { + installedAt: string + /** Absolute path to the `.stlib` archive on disk. */ + stlibPath: string + /** Source format the user installed from — "stlib" for native + * archives, "codesys" for .lib/.library imports. Purely + * informational; UI may surface a badge. */ + origin: 'stlib' | 'codesys' +} + /** * On-disk shape of `{userData}/libraries/registry.json`. Records * user-installed libraries (bundled libs are discovered fresh from @@ -18,23 +29,27 @@ import type { InstalledLibrary, LibraryInstallResult } from '../../../middleware * * Keyed by `name` (the strucpp manifest's library identifier) — the * same value the project's `libraries[].name` field stores, so - * project ↔ system pool joins are O(1). + * project ↔ system pool joins are O(1). Each name then holds every + * installed version, so a project can pin one and still roll back. + * + * `formatVersion` "1.0" held a single version per name inline; it is + * migrated on read by `readRegistry`. */ export type LibraryRegistry = { formatVersion: string libraries: Record< string, { - version: string - installedAt: string - /** Absolute path to the `.stlib` archive on disk. */ - stlibPath: string - /** Source format the user installed from — "stlib" for native - * archives, "codesys" for .lib/.library imports. Purely - * informational; UI may surface a badge. */ - origin: 'stlib' | 'codesys' + /** Keyed by the manifest version string, e.g. "0.0.1". */ + versions: Record } > } +/** A `formatVersion: "1.0"` registry, as it may still be on disk. */ +export type LibraryRegistryV1 = { + formatVersion: string + libraries: Record +} + export type { InstalledLibrary, LibraryInstallResult } 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..c904bd775 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 { extractDocumentation, 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 @@ -191,9 +173,10 @@ function findLastEndVarIndex(content: string, startIndex: number): number { * @returns A partial PLCPou with empty variables array but preserved variablesText */ function createFallbackPou(content: string, language: string, pouType: string, pouName: string): PLCPou { - const docMatch = content.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) - const documentation = docMatch ? docMatch[1].trim() : '' - const remainingContent = docMatch ? content.slice(docMatch[0].length) : content + // Shared with the primary parser: a header written as several consecutive + // comment blocks kept only its first block here, and left the rest in front + // of the declaration the regex below then failed to match. + const { documentation, remainingContent } = extractDocumentation(content) const varStartIndex = remainingContent.search( /\b(VAR_INPUT|VAR_OUTPUT|VAR_IN_OUT|VAR_EXTERNAL|VAR_TEMP|VAR_GLOBAL|VAR)\b/i, @@ -360,7 +343,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 +354,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 21a613b5a..675db0781 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', @@ -163,6 +247,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', @@ -223,6 +308,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 8b79bb4c0..f769abaed 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, @@ -240,6 +241,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 3d839c312..0249bfebb 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -30,6 +30,7 @@ import type { PlatformLog, RuntimeV4Bundle, } from '../../../middleware/shared/ports/compiler-platform-port' +import type { VersionSubstitution } from '../../../middleware/shared/ports/library-types' import type { StructuredCompileError } from '../../../middleware/shared/ports/types' import { composeRuntimeV4Bundle } from '../../../middleware/shared/utils/library/compose-runtime-v4-bundle' import { resolveModbusServerProfile } from '../../../middleware/shared/utils/modbus-server-profile' @@ -43,6 +44,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' @@ -52,6 +54,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 { materialiseOpcUaCredentials } from './opcua-credentials' import { buildCBlocksFromPous, composeFirmwareBundle } from './steps/compose-firmware-bundle' import { generateRuntimeConfs } from './steps/generate-confs' @@ -209,6 +212,9 @@ export interface RunCompilePipelineArgs { * Strucpp's pre-compile gate fails fast on these with a clear * message. */ missingLibraries: string[] + /** Libraries resolved to a version the project does not pin. Reported so a + * build against something other than what the project names is visible. */ + substitutedLibraries?: VersionSubstitution[] /** Firmware skeleton — bundled `Baremetal.ino`, Arduino HAL, * strucpp runtime headers, simulator HAL adapter. Editor: from * filesystem; web: from `import.meta.glob`. Contents byte- @@ -373,6 +379,128 @@ 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>() + + /** + * One source's folders, kept apart until that source is fully collected. + * + * Merging as we go would mix two versions of the same third-party library — + * a header from one and the source from the other, which is a link error at + * best and the wrong behaviour at worst. Two libraries bundling the same + * Arduino dependency is ordinary, so the later folder replaces the earlier + * one whole. + */ + const collect = (resources: readonly unknown[]): void => { + const staged = new Map>() + for (const resource of resources) { + // An archive is external data; a record missing its path or content is + // skipped rather than allowed to abort the compile. + if (typeof resource !== 'object' || resource === null) continue + const { path, content } = resource as { path?: unknown; content?: unknown } + if (typeof path !== 'string' || typeof content !== 'string') continue + // 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(path)) continue + // The first segment names the library folder the file belongs to. + const separator = path.indexOf('/') + if (separator <= 0) continue + const name = path.slice(0, separator) + let files = staged.get(name) + if (!files) { + files = new Map() + staged.set(name, files) + } + files.set(path.slice(separator + 1), content) + } + for (const [name, files] of staged) byLibrary.set(name, files) + } + + for (const archive of enabled.size === 0 ? [] : libraryArchives) { + if (typeof archive !== 'object' || archive === null) continue + const { manifest, resources } = archive as { manifest?: unknown; resources?: unknown } + const archiveName = (manifest as { name?: unknown } | undefined)?.name + if (typeof archiveName !== 'string' || !enabled.has(archiveName)) continue + collect(Array.isArray(resources) ? resources : []) + } + + // A library project does not list itself, so its own resources arrive here + // directly when it verifies. + const own = (projectData as { ownLibraryResources?: unknown }).ownLibraryResources + collect(Array.isArray(own) ? own : []) + + return [...byLibrary].map(([name, files]) => ({ + name, + files: [...files].map(([path, content]) => ({ path, content })), + })) +} + +/** + * The libraries the resource libraries declare in `depends=`. + * + * Read from each folder's `library.properties`, which is the Arduino-native + * way for a library to say what it needs. It is taken from the declaration + * rather than by scanning the sources, because a source's `#include` may sit + * behind a platform guard — emitting `` on an ESP32 build would + * break a sketch that was fine. + */ +function resourceLibraryDepends( + libraries: ReadonlyArray<{ files: ReadonlyArray<{ path: string; content: string }> }>, +): string[] { + const field = (content: string, key: string): string | undefined => { + for (const line of content.split(/\r?\n/)) { + const match = new RegExp(`^\\s*${key}\\s*=\\s*(.+)$`).exec(line) + if (match) return match[1].trim() + } + return undefined + } + + // A library's own name is not its header. `includes=` is where a library + // says what to include, so it is read from the depended library when that + // library is one of ours. For a platform library — Wire, Preferences — its + // properties are not here to read, and `.h` is the convention those + // follow. + const headerByName = new Map() + for (const library of libraries) { + for (const file of library.files) { + if (!file.path.endsWith('library.properties')) continue + const name = field(file.content, 'name') + const includes = field(file.content, 'includes') + if (name && includes) headerByName.set(name, includes.split(',')[0].trim()) + } + } + + const headers: string[] = [] + for (const library of libraries) { + for (const file of library.files) { + if (!file.path.endsWith('library.properties')) continue + const depends = field(file.content, 'depends') + if (!depends) continue + for (const entry of depends.split(',')) { + const name = entry.split('(')[0].trim() + if (!name) continue + headers.push(headerByName.get(name) ?? `${name.replace(/\s+/g, '')}.h`) + } + } + } + return headers +} + export async function runCompilePipeline( args: RunCompilePipelineArgs, port: CompilerPlatformPort, @@ -409,6 +537,7 @@ async function runCompilePipelineInner( compileOnly, libraryArchives, missingLibraries, + substitutedLibraries, firmwareSkeleton, strucppRuntimeHeaders, avrLibStdCppInclude, @@ -450,6 +579,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. @@ -458,6 +588,19 @@ async function runCompilePipelineInner( // transpiler to emit, producing invalid code downstream. Catch it // here and tell the user exactly which POU to fix. // --------------------------------------------------------------------- + // A pinned version that is not installed resolves to the newest one instead, + // rather than stranding the project. Say so: the program being built is not + // the one the project names. + for (const substitution of substitutedLibraries ?? []) { + emit({ + stage: 'validate', + message: + `Library "${substitution.name}": the project pins ${substitution.wanted}, which is not installed. ` + + `Building against ${substitution.used}.`, + level: 'warning', + }) + } + const emptyVariables = findEmptyFbdVariables(processedData) if (emptyVariables.length > 0) { for (const variable of emptyVariables) { @@ -670,12 +813,16 @@ async function runCompilePipelineInner( } emit({ stage: 'runtime-v4-bundle', message: 'Composing Runtime v4 upload bundle...', level: 'info' }) - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) + // 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 ownTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames, ownTypeNames) const bundle: RuntimeV4Bundle = composeRuntimeV4Bundle({ programSt, md5, strucppFiles: strucppFilesMap, + libraryResources, cBlocks: { header: cBlocks.header, code: cBlocks.code }, strucppRuntimeHeaders, confs: { @@ -990,6 +1137,7 @@ async function runCompilePipelineInner( ...(boardEntry.defaultSerial ? { defaultSerial: boardEntry.defaultSerial } : {}), ...(boardEntry.networkInterfaces ? { networkInterfaces: boardEntry.networkInterfaces } : {}), ...(strucppResult.retainBlobSize !== null ? { retainBlobSize: strucppResult.retainBlobSize } : {}), + resourceLibraryDepends: resourceLibraryDepends(libraryResources), }) // A board reached only over Ethernet must never be handed an image with no @@ -1112,10 +1260,16 @@ async function runCompilePipelineInner( // c_blocks header/code + defines.h + optional vpp_config.h). // Pure function. emit({ stage: 'firmware-bundle', message: 'Composing firmware bundle...', level: 'info' }) - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) + // The enabled libraries' data types count as well as the project's, exactly + // as on the Runtime v4 path above: a pin typed by one has to be spelled the + // way strucpp declared it, or the struct field is the bare enumeration while + // the POU member is the wrapper around it. + const userTypeNames = projectAndLibraryTypeNames(projectData, libraryArchives) + const ownTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames, ownTypeNames) const firmwareFiles = composeFirmwareBundle({ strucppFiles: strucppFilesMap, + libraryResources, cBlocks, definesH, vppConfigH, @@ -1130,6 +1284,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 c0a2c2e15..d1e2b50b7 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 @@ -111,9 +116,21 @@ export type CBlocksCodePou = CppPouDataCode * `cBlocks` input shape" case. Caller can either use this or hand * the composer the pre-rendered strings directly. */ +/** + * `userTypeNames` spells pin types; `aliasTypeNames` is what gets a `using`. + * + * They differ because they answer different questions. A pin typed by an + * enabled library's enumeration has to be spelled `IEC_`, so the header + * needs every type in scope, the project's and the libraries'. An alias, on the + * other hand, only compiles if the compiler declared that type for THIS build, + * and it declares a library's type when something uses it. Aliasing the rest + * names types that were never emitted. Pass the project's own types here; a + * library type a block actually names is picked up from the pins. + */ export function buildCBlocksFromPous( originalCppPous: CppPouDataCode[], userTypeNames: Iterable = [], + aliasTypeNames: Iterable = userTypeNames, ): ComposeFirmwareBundleInput['cBlocks'] { if (originalCppPous.length === 0) { // Editor's behaviour: leave the static `c_blocks.h` baseline @@ -127,7 +144,7 @@ export function buildCBlocksFromPous( })) return { header: generateCBlocksHeader(headers, userTypeNames), - code: generateCBlocksCode(originalCppPous, userTypeNames), + code: generateCBlocksCode(originalCppPous, aliasTypeNames), } } @@ -138,6 +155,7 @@ const VENDOR_FACING_CONTRACT_HEADERS = ['openplc_retain.h'] as const * 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) @@ -149,20 +167,66 @@ const VENDOR_FACING_CONTRACT_HEADERS = ['openplc_retain.h'] as const * - `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, opcuaConfigH, s7commConfigH, 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, + opcuaConfigH, + s7commConfigH, + 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) // Copied, not moved: the sketch still compiles its own copy next to openplc_retain_weak.cpp. for (const header of VENDOR_FACING_CONTRACT_HEADERS) { diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 39ced6c61..43fab1795 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -105,6 +105,15 @@ export interface GenerateDefinesInput { * console on a microcontroller to report it, so the check has to happen at * build time or not at all. */ retainBlobSize?: number + + /** Headers for the libraries a resource library declares in `depends=`. + * + * A resource library's sources are precompiled into an archive and moved + * aside before the sketch is linked, so arduino-cli's include scan never + * sees what they need. Naming those libraries here puts an `#include` where + * the scan does reach, which is what makes arduino-cli build them. Without + * it the archive compiles and then fails to link. */ + resourceLibraryDepends?: readonly string[] } /** @@ -138,6 +147,7 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { defaultSerial, networkInterfaces, retainBlobSize, + resourceLibraryDepends, } = input let DEFINES_CONTENT = '' @@ -326,5 +336,16 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += `#define OPLC_RETAIN_BLOB_SIZE ${retainBlobSize}\n` } + // 7. Libraries the installed libraries depend on. The sketch includes this + // header, so an include here is one arduino-cli's scan can follow — see + // `resourceLibraryDepends`. + const depends = [...new Set(resourceLibraryDepends ?? [])].sort() + if (depends.length > 0) { + DEFINES_CONTENT += '\n//Libraries the installed libraries need\n' + for (const name of depends) { + DEFINES_CONTENT += `#include <${name}>\n` + } + } + return DEFINES_CONTENT } 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 bd7fbcd4f..da17c89e7 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 1f2f853e9..955c9f7a4 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -191,6 +191,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 // --------------------------------------------------------------------------- @@ -350,6 +414,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' } as const, extra: {} as Record, } @@ -523,6 +588,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' }, extra: { description: 'a demo lib', displayName: 'Demo Library' }, }, { @@ -633,6 +699,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 1855ef7fc..58c0d7fd7 100644 --- a/src/backend/shared/library/__tests__/inject-library-blocks.test.ts +++ b/src/backend/shared/library/__tests__/inject-library-blocks.test.ts @@ -47,7 +47,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 +72,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 -------------------------------------------------------------------- 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 2afec3ae9..0d92fb04e 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -17,7 +17,7 @@ * unit-level for the orchestration logic itself. */ -import type { LibraryBuildPort } from '../../../../middleware/shared/ports/library-build-port' +import type { LibraryBuildPort, VerifyCompileArgs } from '../../../../middleware/shared/ports/library-build-port' import type { TranspileToStArgs, TranspileToStResult } from '../../../../middleware/shared/ports/compiler-platform-port' import type { PLCProjectData } from '../../types/PLC/open-plc' @@ -30,10 +30,16 @@ const FAKE_PROGRAM_ST = 'PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n' const mockPrepareXml = jest.fn() const mockLibraryBuild = jest.fn() +const mockComposeVerify = jest.fn((project: { meta: unknown; data: unknown }) => ({ + meta: { ...(project.meta as Record), type: 'plc-project' }, + data: project.data, +})) jest.mock('../build-pipeline', () => ({ prepareXmlForLibraryBuild: (...args: unknown[]) => mockPrepareXml(...args), libraryBuildFromTranspiledSt: (...args: unknown[]) => mockLibraryBuild(...args), + composeVerificationProject: (...args: unknown[]) => + mockComposeVerify(...(args as [{ meta: unknown; data: unknown }])), })) import { runLibraryBuildPipeline } from '../library-build-orchestrator' @@ -48,6 +54,8 @@ interface PortHarness { manifestContent: string | null archives: unknown[] missing: string[] + verifyResult: { success: boolean; message?: string } + verifyCalls: VerifyCompileArgs[] transpileResult: TranspileToStResult transpileCalls: TranspileToStArgs[] /** Programmable error for whichever method the test wants to fail. */ @@ -66,12 +74,29 @@ function makePort(): PortHarness { manifestContent: '{"name":"lib","version":"0.1.0","namespace":"lib"}', archives: [], missing: [], + verifyResult: { success: true }, + verifyCalls: [], throwOnRead: new Map(), transpileResult: { ok: true, programSt: FAKE_PROGRAM_ST }, transpileCalls: [], 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 + // distinguishable. + return `md5-${input.length}-${input.charCodeAt(0) ?? 0}` + }, async transpileToSt(args: TranspileToStArgs, log) { if (harness.throwOn.transpileToSt) throw harness.throwOn.transpileToSt harness.transpileCalls.push(args) @@ -86,6 +111,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) @@ -100,10 +142,31 @@ function makePort(): PortHarness { if (harness.throwOn.loadLibraryArchives) throw harness.throwOn.loadLibraryArchives return { archives: harness.archives, missing: harness.missing } }, + async verifyCompile(args) { + harness.verifyCalls.push(args) + args.emit('verifying...', 'info') + return harness.verifyResult + }, } 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: [], @@ -121,11 +184,12 @@ function captureEvents() { beforeEach(() => { mockPrepareXml.mockReset() mockLibraryBuild.mockReset() + mockComposeVerify.mockClear() 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: [] }) }) @@ -143,6 +207,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -152,9 +218,8 @@ describe('runLibraryBuildPipeline', () => { expect(result.libraryName).toBe('lib') expect(result.stlibPath).toBe('build/lib.stlib') expect(harness.files.get('build/lib.stlib')).toMatch(/^\{[\s\S]+\}\n$/) - // The `.stlib` is the ONLY thing the build writes. The verification - // cache that used to sit beside it went out with the verify stage. - expect(harness.files.has('build/.verify-cache-library.json')).toBe(false) + // Verification cache persisted with the MD5 the orchestrator computed. + expect(harness.files.has('build/.verify-cache-library.json')).toBe(true) // Intermediates (program.st) live in memory only — the ST is // produced in-process by `transpileToSt` and never persisted. // See the path-constants comment in library-build-orchestrator.ts. @@ -165,16 +230,22 @@ describe('runLibraryBuildPipeline', () => { 'Starting library build...', 'Manifest OK — building "lib" v0.1.0.', 'Transpiling project to Structured Text', + 'Verifying library compile...', 'Compiling library archive...', 'Library built successfully: build/lib.stlib', ]), ) // The stubbed projectData from Stage 1 is what the transpiler sees. expect(harness.transpileCalls).toHaveLength(1) - // Nothing in the build talks about verification any more. Asserted on the - // whole event stream rather than the `arrayContaining` above, which would - // happily pass with an extra verify line in the middle. - expect(events.filter((e) => /verif/i.test(e.message))).toEqual([]) + // The verify stage runs, forwards the inner compile's own lines under a + // `[verify]` prefix, and reports its outcome. Asserted on the whole event + // stream rather than through the `arrayContaining` above, which would pass + // just as happily if a line went missing. + expect(events.filter((e) => /verif/i.test(e.message)).map((e) => e.message)).toEqual([ + 'Verifying library compile...', + '[verify] verifying...', + 'Verification passed.', + ]) }) it('does not call deleteBuildSubtree (intermediates are no longer persisted)', async () => { @@ -189,6 +260,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -206,6 +279,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -234,6 +309,8 @@ describe('runLibraryBuildPipeline', () => { ...projectDataEmpty(), libraries: [{ name: 'oscat-basic', version: '1.0.0' }], } as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -256,6 +333,8 @@ describe('runLibraryBuildPipeline', () => { ...projectDataEmpty(), libraries: [{ name: 'ghost-lib', version: '1.0.0' }], } as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -267,6 +346,321 @@ describe('runLibraryBuildPipeline', () => { expect(mockLibraryBuild).not.toHaveBeenCalled() }) + it('skips verification when the MD5 cache matches', async () => { + const harness = makePort() + // 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 = verifyInputsMd5() + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + 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 = verifyInputsMd5() + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: true, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('runs a fresh verification when the cache read throws', async () => { + const harness = makePort() + // Throw only on the cache read; the manifest read (Stage 0) must + // still succeed so we reach the cache-consult path. + const realRead = harness.port.readBuildFile.bind(harness.port) + harness.port.readBuildFile = async (projectPath, relPath) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache read blew up') + return realRead(projectPath, relPath) + } + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Cache read failed → treated as a miss → fresh verification runs. + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('runs a fresh verification when the cached file is malformed JSON', async () => { + const harness = makePort() + harness.files.set('build/.verify-cache-library.json', '{ not valid json') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Malformed cache → fall through to a real verification run. + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('surfaces a verification failure as a warning but still emits the .stlib', async () => { + const harness = makePort() + harness.verifyResult = { success: false, message: 'AVR ran out of flash' } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) // verification failure is advisory + expect(result.verification?.success).toBe(false) + expect(result.verification?.message).toBe('AVR ran out of flash') + expect(harness.files.has('build/lib.stlib')).toBe(true) + expect(events.some((e) => e.level === 'warning' && /Verification reported issues/.test(e.message))).toBe(true) + }) + it('propagates strucpp compile errors as a fatal build failure', async () => { const harness = makePort() mockLibraryBuild.mockReturnValueOnce({ @@ -279,6 +673,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -303,7 +699,11 @@ describe('runLibraryBuildPipeline', () => { dataTypes: [{ name: 'MyType', documentation: 'A type description' }], } as unknown as PLCProjectData - await runLibraryBuildPipeline({ projectPath: '/project', projectData }, harness.port, emit) + await runLibraryBuildPipeline( + { projectPath: '/project', projectData, verifyProjectData: projectData, cleanBuild: false }, + harness.port, + emit, + ) const [, , , aux] = mockLibraryBuild.mock.calls[0] expect(aux.pouDocs).toEqual({ MyFb: 'A docstring', MyType: 'A type description' }) @@ -338,6 +738,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData, + verifyProjectData: projectData, + cleanBuild: false, nativePous: [ { name: 'MyCppFb', language: 'cpp', relPath: 'pous/function-blocks/MyCppFb.cpp' }, { name: 'MyPyFb', language: 'python', relPath: 'pous/function-blocks/MyPyFb.py' }, @@ -374,6 +776,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData, + verifyProjectData: projectData, + cleanBuild: false, nativePous: [{ name: 'CPP_ADD', language: 'cpp', relPath: 'pous/functions/CPP_ADD.cpp' }], }, harness.port, @@ -401,6 +805,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -421,6 +827,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, nativePous: [{ name: 'Boom', language: 'cpp', relPath: 'pous/function-blocks/Boom.cpp' }], }, harness.port, @@ -441,6 +849,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, nativePous: [{ name: 'Gone', language: 'cpp', relPath: 'pous/function-blocks/Gone.cpp' }], }, harness.port, @@ -462,6 +872,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -481,6 +893,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -503,6 +917,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -525,6 +941,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, @@ -534,6 +952,151 @@ 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 + // `formatError`. + harness.port.verifyCompile = async () => { + throw 'avr-gcc segfaulted' + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Verification failures are advisory — the build still succeeds. + expect(result.success).toBe(true) + expect(result.verification?.success).toBe(false) + expect(result.verification?.message).toBe('avr-gcc segfaulted') + expect(events.some((e) => e.level === 'warning' && /Verification reported issues/.test(e.message))).toBe(true) + }) + + it('warns but still ships the .stlib when the verification cache cannot be written', async () => { + const harness = makePort() + // Fail only the cache write; the .stlib write happens later and + // must still succeed. + const realWrite = harness.port.writeBuildFile.bind(harness.port) + harness.port.writeBuildFile = async (projectPath, relPath, content) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache dir read-only') + return realWrite(projectPath, relPath, content) + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) + expect(harness.files.has('build/lib.stlib')).toBe(true) + expect(events.some((e) => e.level === 'warning' && /Could not write verification cache/.test(e.message))).toBe(true) + }) + it('fails when the .stlib archive cannot be written', async () => { const harness = makePort() harness.port.writeBuildFile = async (_projectPath, relPath) => { @@ -546,6 +1109,8 @@ describe('runLibraryBuildPipeline', () => { { projectPath: '/project', projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, }, harness.port, emit, diff --git a/src/backend/shared/library/__tests__/read-library-bundle.test.ts b/src/backend/shared/library/__tests__/read-library-bundle.test.ts new file mode 100644 index 000000000..7a4f1bac0 --- /dev/null +++ b/src/backend/shared/library/__tests__/read-library-bundle.test.ts @@ -0,0 +1,148 @@ +/** + * Every ZIP here is built by JSZip, so the fixtures are real archives rather + * than a mock of one. The cases that matter are the ones a user actually hits: + * a bundle zipped on a Mac, a folder of archives, and a ZIP that is not a + * bundle at all. + */ + +import JSZip from 'jszip' + +import { BUNDLE_LIMITS, LibraryBundleError, readLibraryBundle } from '../read-library-bundle' + +/** + * `deflate` matters: JSZip stores uncompressed by default, which makes every + * entry's compression ratio 1:1 and would let the bomb case pass vacuously. + */ +const zipOf = async (files: Record, deflate = false): Promise => { + const zip = new JSZip() + for (const [path, content] of Object.entries(files)) zip.file(path, content) + return zip.generateAsync(deflate ? { type: 'uint8array', compression: 'DEFLATE' } : { type: 'uint8array' }) +} + +describe('reading a bundle', () => { + it('returns every .stlib, whatever folder it sits in', async () => { + const archives = await readLibraryBundle( + await zipOf({ 'a.stlib': '{"a":1}', 'nested/b.stlib': '{"b":2}', 'deep/er/c.stlib': '{"c":3}' }), + ) + + expect(archives.map((archive) => archive.path)).toEqual(['a.stlib', 'deep/er/c.stlib', 'nested/b.stlib']) + if (archives[0].kind !== 'stlib') throw new Error('expected an stlib entry') + expect(archives[0].text).toBe('{"a":1}') + }) + + it('orders by path so two runs install in the same order', async () => { + const archives = await readLibraryBundle(await zipOf({ 'z.stlib': '{}', 'a.stlib': '{}', 'm.stlib': '{}' })) + expect(archives.map((archive) => archive.path)).toEqual(['a.stlib', 'm.stlib', 'z.stlib']) + }) + + it('ignores everything that is not a library file', async () => { + const archives = await readLibraryBundle( + await zipOf({ 'README.md': 'hi', 'lib.stlib': '{}', 'src/thing.st': 'PROGRAM' }), + ) + expect(archives.map((archive) => archive.path)).toEqual(['lib.stlib']) + }) + + it('takes CODESYS files as bytes and .stlib as text', async () => { + // A .lib is binary: reading it as a string would mangle it before the + // importer ever saw it, so the two kinds come back differently on purpose. + const files = await readLibraryBundle( + await zipOf({ 'a.stlib': '{"a":1}', 'OSCAT.library': 'binary', 'old.lib': 'binary' }), + ) + + // Uppercase sorts before lowercase under codepoint order. + expect(files.map((file) => [file.path, file.kind])).toEqual([ + ['OSCAT.library', 'codesys'], + ['a.stlib', 'stlib'], + ['old.lib', 'codesys'], + ]) + const stlib = files.find((file) => file.kind === 'stlib') + const codesys = files.find((file) => file.kind === 'codesys') + if (stlib?.kind !== 'stlib' || codesys?.kind !== 'codesys') throw new Error('expected one of each kind') + expect(stlib.text).toBe('{"a":1}') + expect(codesys.bytes).toBeInstanceOf(Uint8Array) + }) + + it('hands the CODESYS preparer a basename, not a path', async () => { + // The preparer derives the library identifier from the filename, so a + // folder path would end up inside the name. + const files = await readLibraryBundle(await zipOf({ 'vendor/v3/OSCAT.library': 'binary' })) + if (files[0].kind !== 'codesys') throw new Error('expected a codesys entry') + expect(files[0].filename).toBe('OSCAT.library') + expect(files[0].path).toBe('vendor/v3/OSCAT.library') + }) + + it('takes every extension case-insensitively, which Windows produces', async () => { + const archives = await readLibraryBundle(await zipOf({ 'Lib.STLIB': '{}', 'O.LIBRARY': 'x', 'P.LIB': 'x' })) + expect(archives.map((archive) => archive.kind)).toEqual(['stlib', 'codesys', 'codesys']) + }) + + it('skips the resource forks macOS puts in a ZIP', async () => { + // Zipping a folder in Finder writes __MACOSX/._name beside every file. + // They end in .stlib and are not archives, so without this a Mac-authored + // bundle reports half its entries as corrupt. + const archives = await readLibraryBundle( + await zipOf({ + 'libs/real.stlib': '{"real":true}', + '__MACOSX/libs/._real.stlib': ' ', + '._stray.stlib': ' ', + }), + ) + + expect(archives.map((archive) => archive.path)).toEqual(['libs/real.stlib']) + }) +}) + +describe('refusing a ZIP that is not a bundle', () => { + it('refuses bytes that are not a ZIP at all', async () => { + await expect(readLibraryBundle(new TextEncoder().encode('not a zip'))).rejects.toThrow(LibraryBundleError) + }) + + it('refuses a ZIP holding no library, rather than installing nothing quietly', async () => { + await expect(readLibraryBundle(await zipOf({ 'notes.txt': 'hello' }))).rejects.toThrow( + 'ZIP contains no .stlib, .lib or .library files', + ) + }) + + it('refuses one holding only macOS metadata', async () => { + await expect(readLibraryBundle(await zipOf({ '__MACOSX/._a.stlib': 'x', '._b.library': 'x' }))).rejects.toThrow( + 'ZIP contains no .stlib, .lib or .library files', + ) + }) +}) + +describe('limits', () => { + it('refuses more entries than the limit', async () => { + const many: Record = {} + for (let index = 0; index < 12; index += 1) many[`lib${index}.stlib`] = '{}' + + await expect(readLibraryBundle(await zipOf(many), { ...BUNDLE_LIMITS, maxEntries: 10 })).rejects.toThrow( + 'too many files', + ) + }) + + it('refuses an entry larger than the per-entry limit', async () => { + const bytes = await zipOf({ 'big.stlib': 'x'.repeat(5_000) }) + await expect(readLibraryBundle(bytes, { ...BUNDLE_LIMITS, maxEntryBytes: 1_000 })).rejects.toThrow('too large') + }) + + it('refuses a ratio no honest archive reaches', async () => { + // A long run of one character compresses far past anything real source + // text does, which is exactly the shape of a bomb. + const bytes = await zipOf({ 'bomb.stlib': 'A'.repeat(2_000_000) }, true) + await expect(readLibraryBundle(bytes, { ...BUNDLE_LIMITS, maxCompressionRatio: 50 })).rejects.toThrow( + 'compression ratio', + ) + }) + + it('refuses a total past the limit even when each entry is small', async () => { + const bytes = await zipOf({ 'a.stlib': 'x'.repeat(4_000), 'b.stlib': 'y'.repeat(4_000) }) + await expect( + readLibraryBundle(bytes, { ...BUNDLE_LIMITS, maxEntryBytes: 10_000, maxTotalBytes: 5_000 }), + ).rejects.toThrow('too large uncompressed') + }) + + it('accepts a bundle inside the limits', async () => { + const archives = await readLibraryBundle(await zipOf({ 'a.stlib': '{}', 'b.stlib': '{}' })) + expect(archives).toHaveLength(2) + }) +}) diff --git a/src/backend/shared/library/build-pipeline.ts b/src/backend/shared/library/build-pipeline.ts index 86dd7b8d9..d8d7a0fb5 100644 --- a/src/backend/shared/library/build-pipeline.ts +++ b/src/backend/shared/library/build-pipeline.ts @@ -31,6 +31,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' @@ -55,6 +57,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. */ @@ -107,6 +112,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 { @@ -115,6 +123,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, }, } @@ -312,6 +321,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[] @@ -321,6 +350,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[] } /** @@ -453,6 +485,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 @@ -490,6 +523,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 })) + } } /** @@ -508,6 +548,30 @@ function inferCategory(fileName: string): string | undefined { return undefined } +// --------------------------------------------------------------------------- +// Stage 4: verification project (Phase 8) +// --------------------------------------------------------------------------- + +/** + * Build a transient PLCProject the verification compile path + * consumes. Same stub-program shape as `stubProgramFor`, but + * tagged `plc-project` so the existing `compileProgram` flow + * (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 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) + return { + meta: { ...project.meta, type: 'plc-project' }, + data: stubbed.data, + } +} + // Exposed for test ergonomics only — keeps the stub-name constants // and pipeline helpers that aren't part of the public API reachable // from test code without leaking them as importable symbols for diff --git a/src/backend/shared/library/inject-library-blocks.ts b/src/backend/shared/library/inject-library-blocks.ts index 5cf123f89..fc18f0062 100644 --- a/src/backend/shared/library/inject-library-blocks.ts +++ b/src/backend/shared/library/inject-library-blocks.ts @@ -155,6 +155,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 9e4af497d..7db831624 100644 --- a/src/backend/shared/library/library-build-orchestrator.ts +++ b/src/backend/shared/library/library-build-orchestrator.ts @@ -20,8 +20,11 @@ * The ST lives in memory only — no intermediate file persistence * (see path-constants comment). * 3. Resolve project-enabled library archives + fail on missing - * names. - * 4. Gather `pouDocs` from the project data, and read the authored + * names (one place — feeds BOTH verification and strucpp). + * 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 * C/C++ / Python POU files off disk for strucpp to carry verbatim. * 5. strucpp compile via `libraryBuildFromTranspiledSt`. * 6. Write `.stlib` archive to `build/{name}.stlib`. @@ -46,8 +49,20 @@ 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 { libraryBuildFromTranspiledSt, type LibraryNativeSource, prepareXmlForLibraryBuild } from './build-pipeline' +import { isSafeRelativePath } from '../utils/path-safety' +import { + composeVerificationProject, + libraryBuildFromTranspiledSt, + type LibraryNativeSource, + type LibraryResource, + prepareXmlForLibraryBuild, +} from './build-pipeline' import type { NativePouRef } from './native-pou-list' // --------------------------------------------------------------------------- @@ -67,6 +82,11 @@ export interface LibraryBuildArgs { /** Build-pass project data: Python POUs lowered to runtime ST, C/C++ * POUs replaced by stubs with the originals on `originalCppPous`. */ projectData: PLCProjectData + /** Verification-pass project data: Python POUs lowered to no-op stubs — a + * verification compile has no Python interpreter behind it. */ + verifyProjectData: PLCProjectData + /** Skip the MD5 verification cache and force a fresh verify run. */ + cleanBuild: boolean /** Native (C/C++, Python) POUs, collected from the project data BEFORE * `preprocessPous` ran — see `collectNativePous`. The pipeline cannot * derive this itself: by the time it sees `projectData`, every native body @@ -85,12 +105,84 @@ export interface LibraryBuildArgs { // persisted. They're transient artifacts that exist only between // `prepareXmlForLibraryBuild` and `libraryBuildFromTranspiledSt` — // passing them through the port would force every platform to spend -// a round-trip on data nobody reads after the build finishes. The -// user-visible `.stlib` artifact is the only thing this pipeline -// writes to the project tree. +// a round-trip on data nobody reads after the build finishes. Only +// 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`. @@ -105,7 +197,7 @@ export async function runLibraryBuildPipeline( port: LibraryBuildPort, emit: (event: LibraryBuildEvent) => void, ): Promise { - const { projectPath, projectData, nativePous = [] } = args + const { projectPath, projectData, verifyProjectData, cleanBuild, nativePous = [] } = args emit({ message: 'Starting library build...', level: 'info' }) @@ -185,29 +277,6 @@ export async function runLibraryBuildPipeline( ) } - // ------------------------------------------------------------------------- - // Stage 4: gather per-symbol documentation - // - // POUs contribute their editor "Description" field; data types - // contribute their own optional documentation. Both ride through - // `libraryBuildFromTranspiledSt`'s aux block and get stamped onto - // the corresponding manifest entries via `decorateArchive`. (Native - // blocks get their documentation from strucpp instead, which reads the - // leading ST comment out of the authored file.) - // ------------------------------------------------------------------------- - const pouDocs: Record = {} - for (const pou of projectData.pous) { - if (pou.data.documentation && pou.data.documentation.length > 0) { - pouDocs[pou.data.name] = pou.data.documentation - } - } - for (const dt of projectData.dataTypes ?? []) { - const doc = (dt as { documentation?: string }).documentation - const name = (dt as { name?: string }).name - if (typeof name === 'string' && typeof doc === 'string' && doc.length > 0) { - pouDocs[name] = doc - } - } // ------------------------------------------------------------------------- // Stage 4b: read the authored C/C++ and Python POU files off the project // @@ -219,6 +288,11 @@ export async function runLibraryBuildPipeline( // // 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) { @@ -241,7 +315,124 @@ export async function runLibraryBuildPipeline( } // ------------------------------------------------------------------------- - // Stage 5: strucpp compileStlib + // Stage 5: verification compile + // + // 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 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 (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 + emit({ + message: `Skipping verification (cached: ${cached.success ? 'pass' : 'fail'}). Use "Clean build" to force re-verification.`, + level: 'info', + }) + } + } + if (!verification && verifyTarget.mode !== 'off') { + const verifyProject = composeVerificationProject({ + meta: { name: manifest.name, type: 'plc-library' }, + data: verifyProjectData, + }) + emit({ message: 'Verifying library compile...', level: 'info' }) + try { + verification = await port.verifyCompile({ + projectPath, + // 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 + // the console would falsely suggest the build failed. + const level = logLevel === 'error' ? 'warning' : (logLevel ?? 'info') + emit({ message: `[verify] ${message}`, level }) + }, + }) + } catch (error) { + verification = { success: false, message: formatError(error) } + } + if (verification.success) { + emit({ message: 'Verification passed.', level: 'info' }) + } else { + emit({ + message: `Verification reported issues (warning only — .stlib will still be generated): ${verification.message ?? 'see log'}`, + level: 'warning', + }) + } + } + if (!usedCache && verification) { + try { + await port.writeBuildFile( + projectPath, + VERIFY_CACHE_REL_PATH, + JSON.stringify({ md5: verifyInputsMd5, ...verification }, null, 2), + ) + } catch (cacheErr) { + emit({ message: `Could not write verification cache: ${formatError(cacheErr)}`, level: 'warning' }) + } + } + + // ------------------------------------------------------------------------- + // Stage 6: gather per-symbol documentation + // + // POUs contribute their editor "Description" field; data types + // contribute their own optional documentation. Both ride through + // `libraryBuildFromTranspiledSt`'s aux block and get stamped onto + // the corresponding manifest entries via `decorateArchive`. (Native + // blocks get their documentation from strucpp instead, which reads the + // leading ST comment out of the authored file.) + // ------------------------------------------------------------------------- + const pouDocs: Record = {} + for (const pou of projectData.pous) { + if (pou.data.documentation && pou.data.documentation.length > 0) { + pouDocs[pou.data.name] = pou.data.documentation + } + } + for (const dt of projectData.dataTypes ?? []) { + const doc = (dt as { documentation?: string }).documentation + const name = (dt as { name?: string }).name + if (typeof name === 'string' && typeof doc === 'string' && doc.length > 0) { + pouDocs[name] = doc + } + } + // ------------------------------------------------------------------------- + // Stage 7: strucpp compileStlib // ------------------------------------------------------------------------- emit({ message: 'Compiling library archive...', level: 'info' }) const stage5 = libraryBuildFromTranspiledSt(programSt, knownPous, manifest, { @@ -249,6 +440,7 @@ export async function runLibraryBuildPipeline( dependencyArchives: depArchives, dependencyRefs: enabledLibraryRefs, nativeSources, + resources, }) if (!stage5.success) { for (const err of stage5.errors) { @@ -277,6 +469,7 @@ export async function runLibraryBuildPipeline( success: true, stlibPath: stlibRelPath, libraryName: manifest.name, + verification, } } @@ -284,6 +477,36 @@ export async function runLibraryBuildPipeline( // Helpers // --------------------------------------------------------------------------- +/** + * Read + validate the verification cache. Returns the cached + * `{ success, message }` only when the persisted MD5 matches the + * 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, + verifyInputsMd5: string, +): Promise<{ success: boolean; message?: string } | null> { + let raw: string | null + try { + raw = await port.readBuildFile(projectPath, VERIFY_CACHE_REL_PATH) + } catch { + return null + } + if (raw === null) return null + try { + const parsed = JSON.parse(raw) as { md5?: string; success?: boolean; message?: string } + if (parsed?.md5 === verifyInputsMd5 && typeof parsed.success === 'boolean') { + return { success: parsed.success, message: parsed.message } + } + } catch { + /* malformed cache — fall through to fresh run */ + } + return null +} + function fail( emit: (event: LibraryBuildEvent) => void, message: string, diff --git a/src/backend/shared/library/read-library-bundle.ts b/src/backend/shared/library/read-library-bundle.ts new file mode 100644 index 000000000..72c3cf8fe --- /dev/null +++ b/src/backend/shared/library/read-library-bundle.ts @@ -0,0 +1,168 @@ +/** + * Read the library files out of a ZIP the user picked. + * + * A library ships as several files more often than not — a vendor set, or one + * library built for several versions — and picking them one at a time is the + * whole reason this exists. Both formats the manager installs are recognised: + * a `.stlib` comes back as text, a CODESYS `.lib`/`.library` as bytes, since + * that is what each one's preparer takes. Installing is the caller's job, so + * this stays free of the filesystem and of strucpp. + * + * The ZIP is untrusted: it arrives from wherever the user got it. Nothing here + * writes to disk, so a traversing entry path cannot escape anything, but a + * bomb still costs memory — declared sizes are checked before a single entry + * is decompressed. + */ + +import JSZip from 'jszip' + +export class LibraryBundleError extends Error { + constructor(message: string) { + super(message) + this.name = 'LibraryBundleError' + } +} + +export interface BundleLimits { + maxEntries: number + maxEntryBytes: number + maxTotalBytes: number + maxCompressionRatio: number +} + +/** + * Smaller than the project-snapshot limits: an archive of ST source and + * compiled chunks is measured in megabytes, and a zip of them in tens. + */ +export const BUNDLE_LIMITS: BundleLimits = { + maxEntries: 2_000, + maxEntryBytes: 32 * 1024 * 1024, + maxTotalBytes: 256 * 1024 * 1024, + /** JSON of source text compresses well, but not this well. */ + maxCompressionRatio: 1_000, +} as const + +/** Extensions the manager can install, and which preparer each one goes to. */ +const LIBRARY_EXTENSIONS: ReadonlyArray<{ suffix: string; kind: 'stlib' | 'codesys' }> = [ + { suffix: '.stlib', kind: 'stlib' }, + { suffix: '.library', kind: 'codesys' }, + { suffix: '.lib', kind: 'codesys' }, +] + +/** + * One library file found in the bundle. `path` is the entry path inside the + * ZIP so a failure names something the user can find. + */ +export type BundledLibraryFile = + | { path: string; kind: 'stlib'; text: string } + | { + path: string + kind: 'codesys' + bytes: Uint8Array + /** Basename only: the CODESYS preparer derives the library identifier + * from it, and a folder path would end up in the name. */ + filename: string + } + +/** + * True for entries a ZIP carries that the user never put there. + * + * macOS writes a `__MACOSX/` tree of `._name` resource forks beside the real + * files. They carry the same extensions and are not libraries, so without this + * every Mac-authored bundle reports half its entries as corrupt. + */ +function isMetadataEntry(path: string): boolean { + const segments = path.split('/') + return segments.some((segment) => segment === '__MACOSX') || segments[segments.length - 1].startsWith('._') +} + +/** The kind this entry installs as, or null when it is not a library file. */ +function libraryKind(path: string): 'stlib' | 'codesys' | null { + const lower = path.toLowerCase() + return LIBRARY_EXTENSIONS.find((entry) => lower.endsWith(entry.suffix))?.kind ?? null +} + +/** + * Every library file in the bundle, in path order so two runs install the same + * way. + * + * Throws when the ZIP will not open or holds no library at all — both are the + * user picking the wrong file, which is worth saying plainly rather than + * reporting as an install of nothing. + */ +export async function readLibraryBundle( + bytes: Uint8Array, + limits: BundleLimits = BUNDLE_LIMITS, +): Promise { + let zip: JSZip + try { + zip = await JSZip.loadAsync(bytes) + } catch (error) { + throw new LibraryBundleError( + `Not a readable ZIP archive: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + const entries = Object.values(zip.files).filter((entry) => !entry.dir) + if (entries.length > limits.maxEntries) { + throw new LibraryBundleError(`ZIP has too many files (${entries.length}, limit ${limits.maxEntries})`) + } + + const wanted = entries + .filter((entry) => { + const path = entry.name.replace(/\\/g, '/') + return libraryKind(path) !== null && !isMetadataEntry(path) + }) + // Codepoint order, not `localeCompare`: collation varies with the host's + // ICU data, and the point of sorting at all is that two machines install + // the same bundle in the same order. + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + + if (wanted.length === 0) { + throw new LibraryBundleError('ZIP contains no .stlib, .lib or .library files') + } + + // Declared sizes first: decompressing is what a bomb costs, so the refusal + // has to come before any of it. + let declaredTotal = 0 + for (const entry of wanted) { + const meta = (entry as unknown as { _data?: { uncompressedSize?: number; compressedSize?: number } })._data + const uncompressed = meta?.uncompressedSize ?? 0 + const compressed = meta?.compressedSize ?? 0 + + if (uncompressed > limits.maxEntryBytes) { + throw new LibraryBundleError(`ZIP entry is too large: ${entry.name}`) + } + if (compressed > 0 && uncompressed / compressed > limits.maxCompressionRatio) { + throw new LibraryBundleError(`ZIP entry has a suspicious compression ratio: ${entry.name}`) + } + declaredTotal += uncompressed + } + if (declaredTotal > limits.maxTotalBytes) { + throw new LibraryBundleError( + `ZIP is too large uncompressed (${declaredTotal} bytes, limit ${limits.maxTotalBytes})`, + ) + } + + const files: BundledLibraryFile[] = [] + let readTotal = 0 + for (const entry of wanted) { + const path = entry.name.replace(/\\/g, '/') + if (libraryKind(path) === 'stlib') { + const text = await entry.async('string') + readTotal += text.length + files.push({ path, kind: 'stlib', text }) + } else { + // CODESYS libraries are binary, so they cannot go through `string` -- + // that would mangle them before the importer ever saw them. + const bytes = await entry.async('uint8array') + readTotal += bytes.byteLength + files.push({ path, kind: 'codesys', bytes, filename: path.split('/').pop() ?? path }) + } + // The backstop for an entry whose declared sizes were absent or lied. + if (readTotal > limits.maxTotalBytes) { + throw new LibraryBundleError(`ZIP is too large uncompressed (limit ${limits.maxTotalBytes} bytes)`) + } + } + return files +} 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 e139cd1f4..bef8017d6 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts @@ -76,6 +76,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 3b04d6e9a..ba73ff302 100644 --- a/src/backend/shared/transpilers/st-transpiler/from-schema.ts +++ b/src/backend/shared/transpilers/st-transpiler/from-schema.ts @@ -139,6 +139,9 @@ 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 67cce473d..dc2e5b78f 100644 --- a/src/backend/shared/transpilers/st-transpiler/types.ts +++ b/src/backend/shared/transpilers/st-transpiler/types.ts @@ -33,6 +33,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 45eff13cf..43e1c8fda 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -251,6 +251,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/PLC/preprocess-pous.ts b/src/backend/shared/utils/PLC/preprocess-pous.ts index 6f859faf2..d4b9b9b73 100644 --- a/src/backend/shared/utils/PLC/preprocess-pous.ts +++ b/src/backend/shared/utils/PLC/preprocess-pous.ts @@ -289,6 +289,20 @@ function preprocessPous( pou.interface?.variables ?? [], })) + // A pin typed by a function block is an alias for the caller's instance, so + // the field it fills already holds a pointer. Collected from the project's + // own POUs and from every installed library. + const functionBlockNames = new Set( + processedProjectData.pous + .filter((candidate: PLCPou) => candidate.pouType === 'function-block') + .map((candidate: PLCPou) => candidate.name.toUpperCase()), + ) + for (const library of libraries ?? []) { + for (const block of library.functionBlocks) { + functionBlockNames.add(block.name.toUpperCase()) + } + } + processedProjectData.pous = processedProjectData.pous.map((pou: PLCPou) => { if (pou.body.language === 'cpp') { const stCode = generateCppSTCode({ @@ -296,6 +310,7 @@ function preprocessPous( allVariables: /* istanbul ignore next -- defensive: interface may be undefined */ pou.interface?.variables ?? [], + functionBlockNames, }) return { diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index 764f2d12b..a618dd5fa 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 }, @@ -345,4 +345,51 @@ 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 d1b874ba9..10f00d88f 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 04400ff72..7802bede6 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 = { @@ -209,7 +213,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 086d852bd..ce903e04c 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -300,6 +300,11 @@ function parsePouFile( pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType as string | undefined, + // Emitted verbatim as `EXTENDS `, so anything but a name + // would be written into the generated ST as `[object Object]`. + ...(typeof ipcPou.data.extends === 'string' && ipcPou.data.extends.trim() !== '' + ? { extends: ipcPou.data.extends } + : {}), 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..517d125cb --- /dev/null +++ b/src/cli/__tests__/library.test.ts @@ -0,0 +1,281 @@ +/** + * `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(), +})) + +// Mutable so a test can describe the store it wants without re-mocking. +const libraryStore: { + installed: Array> + archives: Record + uninstalled: Array<{ name: string; version?: string }> + uninstallResult: { success: boolean; error?: string } +} = { + installed: [], + archives: {}, + uninstalled: [], + uninstallResult: { success: true }, +} + +jest.mock('@root/backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn().mockImplementation(() => ({ + listInstalled: () => libraryStore.installed, + installFromFile: jest.fn(), + loadAll: () => [], + loadEnabledArchives: () => ({ archives: [], missing: [] }), + readArchiveText: (name: string, version?: string) => + libraryStore.archives[version ? `${name}@${version}` : name] ?? null, + uninstall: (name: string, version?: string) => { + libraryStore.uninstalled.push({ name, version }) + if (libraryStore.uninstallResult.success) { + libraryStore.installed = libraryStore.installed + .map((row) => ({ ...row, versions: ((row.versions as string[]) ?? []).filter((v) => v !== version) })) + .filter((row) => (row.versions as string[]).length > 0) + } + return libraryStore.uninstallResult + }, + })), +})) + +const MODBEE = { name: 'modbee-protocol', version: '0.1.0', bundled: false, installedAt: '', origin: 'stlib' } + +beforeEach(() => { + libraryStore.installed = [{ ...MODBEE, versions: ['0.1.0'] }] + libraryStore.archives = {} + libraryStore.uninstalled = [] + libraryStore.uninstallResult = { success: true } +}) + +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 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, uninstall, info, list, pin or unpin') + }) + + 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') + }) + + it('carries every installed version in the JSON, not just the newest', async () => { + libraryStore.installed = [{ ...MODBEE, version: '0.2.0', versions: ['0.2.0', '0.1.0'] }] + + const { payload } = await run(['library', 'list']) + + expect(payload.libraries[0].versions).toEqual(['0.2.0', '0.1.0']) + }) + + describe('uninstall', () => { + it('asks for a name when given none', async () => { + const { result, payload } = await run(['library', 'uninstall']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('name@version') + }) + + it('reports a library that is not installed', async () => { + const { result, payload } = await run(['library', 'uninstall', 'phantom']) + expect(result.exitCode).toBe(ExitCode.NotFound) + expect(payload.error.code).toBe(ErrorCode.TargetError) + }) + + it('removes the only version without being told which', async () => { + const { result, payload } = await run(['library', 'uninstall', 'modbee-protocol']) + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.removed).toEqual(['0.1.0']) + expect(libraryStore.uninstalled).toEqual([{ name: 'modbee-protocol', version: '0.1.0' }]) + }) + + it('refuses to guess when several versions are installed', async () => { + libraryStore.installed = [{ ...MODBEE, version: '0.2.0', versions: ['0.2.0', '0.1.0'] }] + + const { result, payload } = await run(['library', 'uninstall', 'modbee-protocol']) + + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('0.2.0, 0.1.0') + expect(libraryStore.uninstalled).toEqual([]) + }) + + it('removes the version named with @', async () => { + libraryStore.installed = [{ ...MODBEE, version: '0.2.0', versions: ['0.2.0', '0.1.0'] }] + + const { result, payload } = await run(['library', 'uninstall', 'modbee-protocol@0.1.0']) + + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.removed).toEqual(['0.1.0']) + expect(payload.remaining).toEqual(['0.2.0']) + }) + + it('removes every version under --all', async () => { + libraryStore.installed = [{ ...MODBEE, version: '0.2.0', versions: ['0.2.0', '0.1.0'] }] + + const { result, payload } = await run(['library', 'uninstall', 'modbee-protocol', '--all']) + + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.removed).toEqual(['0.2.0', '0.1.0']) + }) + + it('passes a refusal from the manager through', async () => { + libraryStore.installed = [{ ...MODBEE, bundled: true, versions: ['0.1.0'] }] + libraryStore.uninstallResult = { success: false, error: "Cannot uninstall bundled library 'modbee-protocol'" } + + const { result, payload } = await run(['library', 'uninstall', 'modbee-protocol']) + + expect(result.exitCode).toBe(ExitCode.TargetError) + expect(payload.error.message).toContain('bundled') + }) + }) + + describe('info', () => { + const archive = { + manifest: { + name: 'modbee-protocol', + version: '0.1.0', + namespace: 'modbee', + description: 'Ring protocol blocks', + isBuiltin: false, + functions: [ + { name: 'SCALE', returnType: 'REAL', parameters: [{ name: 'Raw', type: 'INT', direction: 'input' }] }, + ], + functionBlocks: [ + { + name: 'ANALOG_IN', + inputs: [{ name: 'CH', type: 'INT' }], + outputs: [{ name: 'VAL', type: 'REAL' }], + inouts: [], + }, + ], + types: [{ name: 'MB_TABLE', kind: 'enum' }], + }, + sources: [{ fileName: 'ring.cpp', source: '' }], + } + + it('asks for a name when given none', async () => { + const { result, payload } = await run(['library', 'info']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('name@version') + }) + + it('reports a library it cannot read', async () => { + const { result, payload } = await run(['library', 'info', 'phantom']) + expect(result.exitCode).toBe(ExitCode.NotFound) + expect(payload.error.code).toBe(ErrorCode.TargetError) + }) + + it('reports the pins of every block, which is what list cannot', async () => { + libraryStore.archives['modbee-protocol'] = JSON.stringify(archive) + + const { result, payload } = await run(['library', 'info', 'modbee-protocol']) + + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.library.functionBlocks[0].outputs).toEqual([{ name: 'VAL', type: 'REAL' }]) + expect(payload.library.functions[0].returnType).toBe('REAL') + expect(payload.library.types).toEqual([{ name: 'MB_TABLE', kind: 'enum' }]) + expect(payload.library.sources).toEqual(['ring.cpp']) + }) + + it('reads the version named with @', async () => { + libraryStore.installed = [{ ...MODBEE, versions: ['0.1.0', '0.0.9'] }] + libraryStore.archives['modbee-protocol@0.0.9'] = JSON.stringify(archive) + + const { result } = await run(['library', 'info', 'modbee-protocol@0.0.9']) + + expect(result.exitCode).toBe(ExitCode.Ok) + }) + + it('refuses a version that is not installed rather than showing another one', async () => { + // `readArchiveText` resolves through `resolveVersion`, which substitutes + // the newest — right for a compile, which reports the substitution, and + // wrong here: it printed 0.1.0's blocks under the heading 9.9.9. + libraryStore.archives['modbee-protocol'] = JSON.stringify(archive) + + const { result, payload } = await run(['library', 'info', 'modbee-protocol@9.9.9']) + + expect(result.exitCode).toBe(ExitCode.NotFound) + expect(payload.error.message).toContain('version 9.9.9 is not installed') + expect(payload.error.message).toContain('0.1.0') + }) + + it('reports an archive that is not readable JSON', async () => { + libraryStore.archives['modbee-protocol'] = '{ not json' + + const { result, payload } = await run(['library', 'info', 'modbee-protocol']) + + expect(result.exitCode).toBe(ExitCode.TargetError) + expect(payload.error.message).toContain('unreadable') + }) + }) + + describe('pin and unpin', () => { + it('asks for both arguments when pin is given none', async () => { + const { result, payload } = await run(['library', 'pin']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('name@version') + }) + + it('insists on a version, since a pin without one is what it exists to set', async () => { + const { result, payload } = await run(['library', 'pin', './proj', 'modbee-protocol']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('needs a version') + }) + + it('asks for both arguments when unpin is given none', async () => { + const { result, payload } = await run(['library', 'unpin', './proj']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('library name') + }) + }) +}) diff --git a/src/cli/__tests__/load-hydration.test.ts b/src/cli/__tests__/load-hydration.test.ts new file mode 100644 index 000000000..6d846f994 --- /dev/null +++ b/src/cli/__tests__/load-hydration.test.ts @@ -0,0 +1,119 @@ +/** + * `loadProject` hydrates the library pool, and does it FIRST. + * + * `handleOpenProjectResponse` reads `libraries.system` and restamps every + * placed block against it inside the same action, so a pool hydrated afterwards + * is a pool the restamp never saw — it returns `poolEmpty` and the project's + * missing/outdated lists stay empty. + * + * Ordering is the whole assertion. `jest-vi-shim.ts` already hydrates system + * libraries for every spec in this repo, so a test that merely checks the pool + * is populated passes whether or not `loadProject` does anything at all. The + * store is mocked here for the same reason it is spied on elsewhere: its real + * state is immer-frozen and cannot be instrumented in place. + */ + +import { loadProject } from '../project/load' + +const archives = [{ manifest: { name: 'demo', version: '1.0.0' } }] + +/** Call order, in the order `loadProject` drives the store. */ +const calls: string[] = [] +const record = + (name: string) => + (...args: unknown[]) => { + calls.push(name) + return args + } + +let setSystemLibraries: jest.Mock +let setBundledLibraryNames: jest.Mock + +jest.mock('@root/frontend/store', () => ({ + openPLCStoreBase: { + getState: () => ({ + deviceActions: { setAvailableOptions: record('setAvailableOptions') }, + libraryActions: { + setSystemLibraries: setSystemLibraries, + setBundledLibraryNames: setBundledLibraryNames, + }, + sharedWorkspaceActions: { handleOpenProjectResponse: record('handleOpenProjectResponse') }, + project: { meta: { name: 'demo' }, data: {} }, + projectActions: { getCompileReadyProjectData: () => ({}) }, + deviceDefinitions: { + configuration: { deviceBoard: 'Uno', vendorScreenData: undefined, communicationPort: undefined }, + }, + }), + }, +})) + +jest.mock('@root/backend/editor/hardware', () => ({ + HardwareModule: jest.fn().mockImplementation(() => ({ getAvailableBoards: async () => [] })), +})) + +jest.mock('@root/backend/editor/services', () => ({ + ProjectService: jest.fn().mockImplementation(() => ({ + readRawProjectFiles: async () => ({ + success: true, + data: { projectPath: '/tmp/p', projectJson: {}, pouFiles: [], dataTypeFiles: [] }, + }), + })), +})) + +jest.mock('@root/backend/shared/utils/parse-project-files', () => ({ + parseProjectFiles: () => ({ warnings: ['a parse warning'] }), +})) + +jest.mock('@root/backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn().mockImplementation(() => ({ + loadAll: () => archives, + listInstalled: () => [ + { name: 'bundled-one', bundled: true }, + { name: 'user-one', bundled: false }, + ], + })), +})) + +jest.mock('@root/frontend/utils/stlib-to-system-library', () => ({ + stlibsToSystemLibraries: (input: unknown) => input, +})) + +beforeEach(() => { + calls.length = 0 + setSystemLibraries = jest.fn(record('setSystemLibraries')) + setBundledLibraryNames = jest.fn(record('setBundledLibraryNames')) +}) + +describe('loadProject library hydration', () => { + it('sets the system libraries before opening the project', async () => { + await loadProject('/tmp/p') + + expect(calls).toEqual([ + 'setAvailableOptions', + 'setSystemLibraries', + 'setBundledLibraryNames', + 'handleOpenProjectResponse', + ]) + }) + + it('passes the installed archives through, and names only the bundled ones', async () => { + await loadProject('/tmp/p') + + expect(setSystemLibraries).toHaveBeenCalledWith(archives) + expect(setBundledLibraryNames).toHaveBeenCalledWith(['bundled-one']) + }) + + it('warns rather than failing the load when the library store cannot be read', async () => { + setSystemLibraries = jest.fn(() => { + throw new Error('registry unreadable') + }) + + const result = await loadProject('/tmp/p') + + expect(result.success).toBe(true) + expect(result.success && result.project.warnings).toEqual([ + 'warning: could not read the installed libraries: registry unreadable', + 'a parse warning', + ]) + }) +}) diff --git a/src/cli/commands/library-pin.ts b/src/cli/commands/library-pin.ts new file mode 100644 index 000000000..f2e587526 --- /dev/null +++ b/src/cli/commands/library-pin.ts @@ -0,0 +1,240 @@ +/** + * `openplc-cli library pin` / `unpin` — choose which installed version of a + * library a project compiles against. + * + * The pin lives in `project.json` as `data.libraries[]`, and the compiler + * resolves it through `loadEnabledArchives` → `resolveVersion`, so changing it + * changes the generated code. The GUI drives this from the Library Manager's + * Project Libraries tab; without these commands the version model cannot be + * exercised from a script at all. + * + * The write is surgical — `withProjectLibraries` replaces one field and leaves + * the rest of the document alone, the same helper the GUI's save path uses. + * + * Placed blocks are REPORTED, not rewritten. Applying a library-added pin needs + * the language's own `getBlockSize`, which lives in the components layer the CLI + * may not import; the GUI reconciles on the next project open. + */ + +import { openPLCStoreBase } from '@root/frontend/store' +import type { FBDFlowType, LadderFlowType } from '@root/frontend/store/slices' +import type { SystemLibrary } from '@root/frontend/store/slices/library/types' +import { type ProjectLibraryRef, withProjectLibraries } from '@root/frontend/utils/PLC/project-libraries-json' +import { + type RestampChange, + restampFlowLibraryVariants, + summariseRestampChanges, +} from '@root/frontend/utils/PLC/restamp-library-variants' +import { readFile, writeFile } from 'fs/promises' +import { join } from 'path' + +import { ErrorCode, ExitCode } from '../exit-codes' +import type { CliResult, Reporter } from '../output' +import { loadProject } from '../project/load' + +interface PinnedProject { + projectPath: string + refs: ProjectLibraryRef[] + warnings: string[] +} + +/** + * Load the project and hand back its current refs. `loadProject` hydrates the + * library pool first, which is what makes the restamp below meaningful. + */ +async function openForPinning( + reporter: Reporter, + projectPath: string, +): Promise<{ ok: true; project: PinnedProject } | { ok: false; result: CliResult }> { + const loaded = await loadProject(projectPath) + if (!loaded.success) { + return { + ok: false, + result: reporter.failure({ code: ErrorCode.ProjectNotFound, message: loaded.error }, ExitCode.NotFound), + } + } + for (const warning of loaded.project.warnings) reporter.progress(warning) + + return { + ok: true, + project: { + projectPath: loaded.project.projectPath, + refs: (loaded.project.data.libraries ?? []).map((ref) => ({ name: ref.name, version: ref.version })), + warnings: loaded.project.warnings, + }, + } +} + +/** Rewrite `data.libraries` in place, touching nothing else in the document. */ +async function writeRefs( + projectPath: string, + refs: ProjectLibraryRef[], +): Promise<{ ok: true } | { ok: false; error: string }> { + const fullPath = join(projectPath, 'project.json') + let onDisk: string + try { + onDisk = await readFile(fullPath, 'utf-8') + } catch { + return { ok: false, error: `Could not read ${fullPath}` } + } + + const rewritten = withProjectLibraries(onDisk, refs) + if (!rewritten.ok) return { ok: false, error: rewritten.error } + + try { + await writeFile(fullPath, rewritten.json, 'utf-8') + } catch (err) { + return { ok: false, error: `Could not write ${fullPath}: ${err instanceof Error ? err.message : String(err)}` } + } + return { ok: true } +} + +/** + * Re-point the store at the new refs and report what the placed blocks would + * become. Flows are cloned because the restamp mutates in place and the store + * freezes its state. + */ +function describePlacedBlockDrift(refs: ProjectLibraryRef[]): { changes: RestampChange[]; poolEmpty: boolean } { + openPLCStoreBase.getState().libraryActions.setProjectLibraries(refs) + const state = openPLCStoreBase.getState() + const systemLibraries: SystemLibrary[] = state.libraries.system + const userPous = state.project.data.pous.filter((pou) => pou.pouType !== 'program') + + const changes: RestampChange[] = [] + let poolEmpty = false + + for (const pou of state.project.data.pous) { + if (pou.body.language !== 'ld' && pou.body.language !== 'fbd') continue + const flow = structuredClone(pou.body.value) as LadderFlowType | FBDFlowType + const report = restampFlowLibraryVariants([flow], systemLibraries, userPous, { pou: pou.name }) + changes.push(...report.changes) + poolEmpty = poolEmpty || report.poolEmpty + } + + return { changes, poolEmpty } +} + +function reportDrift(reporter: Reporter, drift: { changes: RestampChange[]; poolEmpty: boolean }): void { + if (drift.poolEmpty) { + reporter.progress(' warning: no libraries are installed, so placed blocks were not checked.') + return + } + for (const line of summariseRestampChanges(drift.changes)) { + reporter.progress(` ${line.severity === 'info' ? '' : `${line.severity}: `}${line.message}`) + } +} + +export async function runLibraryPin( + reporter: Reporter, + projectPath: string | undefined, + ref: string | undefined, +): Promise { + if (!projectPath || !ref) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library pin needs a project path and name@version.' }, + ExitCode.Usage, + ) + } + + const at = ref.lastIndexOf('@') + if (at <= 0) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: `library pin needs a version: ${ref}@.` }, + ExitCode.Usage, + ) + } + const name = ref.slice(0, at) + const version = ref.slice(at + 1) + + const opened = await openForPinning(reporter, projectPath) + if (!opened.ok) return opened.result + + // Refuse a pin the compiler would only silently substitute later. + const installed = openPLCStoreBase + .getState() + .installedLibraries.filter((library: SystemLibrary) => library.name === name) + .map((library: SystemLibrary) => library.version) + if (installed.length === 0) { + return reporter.failure( + { code: ErrorCode.TargetError, message: `Library '${name}' is not installed.` }, + ExitCode.NotFound, + ) + } + if (!installed.includes(version)) { + return reporter.failure( + { + code: ErrorCode.TargetError, + message: `Library '${name}' version ${version} is not installed. Installed: ${installed.join(', ')}.`, + }, + ExitCode.NotFound, + ) + } + + const previous = opened.project.refs.find((entry) => entry.name === name)?.version ?? null + const nextRefs = opened.project.refs.some((entry) => entry.name === name) + ? opened.project.refs.map((entry) => (entry.name === name ? { name, version } : entry)) + : [...opened.project.refs, { name, version }] + + const written = await writeRefs(opened.project.projectPath, nextRefs) + if (!written.ok) { + return reporter.failure({ code: ErrorCode.Internal, message: written.error }, ExitCode.Internal) + } + + reporter.progress(previous === null ? `Added ${name} ${version}.` : `Repinned ${name} ${previous} → ${version}.`) + const drift = describePlacedBlockDrift(nextRefs) + reportDrift(reporter, drift) + + return reporter.success( + { + ok: true, + library: name, + previous, + version, + placedBlockChanges: drift.changes.length, + }, + () => + [ + previous === null ? `Pinned ${name} ${version}` : `Pinned ${name} ${version} (was ${previous})`, + drift.changes.length > 0 + ? ` ${drift.changes.length} placed-block change(s) reported — open the project to apply them` + : '', + ] + .filter(Boolean) + .join('\n'), + ) +} + +export async function runLibraryUnpin( + reporter: Reporter, + projectPath: string | undefined, + name: string | undefined, +): Promise { + if (!projectPath || !name) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library unpin needs a project path and a library name.' }, + ExitCode.Usage, + ) + } + + const opened = await openForPinning(reporter, projectPath) + if (!opened.ok) return opened.result + + const current = opened.project.refs.find((entry) => entry.name === name) + if (!current) { + return reporter.failure( + { code: ErrorCode.TargetError, message: `The project does not reference '${name}'.` }, + ExitCode.NotFound, + ) + } + + const nextRefs = opened.project.refs.filter((entry) => entry.name !== name) + const written = await writeRefs(opened.project.projectPath, nextRefs) + if (!written.ok) { + return reporter.failure({ code: ErrorCode.Internal, message: written.error }, ExitCode.Internal) + } + + return reporter.success( + { ok: true, library: name, removed: current.version, libraries: nextRefs }, + () => `Removed ${name} ${current.version} from the project`, + ) +} diff --git a/src/cli/commands/library.ts b/src/cli/commands/library.ts new file mode 100644 index 000000000..62417677f --- /dev/null +++ b/src/cli/commands/library.ts @@ -0,0 +1,502 @@ +/** + * `openplc-cli library` — build a Library Project into a `.stlib`, install and + * uninstall one, inspect its contents, list what is installed, and pin a + * project to the version it compiles against. + * + * 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 { StlibArchiveDTO } from '@root/middleware/shared/ports/library-port' +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' +import { runLibraryPin, runLibraryUnpin } from './library-pin' + +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 'uninstall': + return runLibraryUninstall(args, reporter, target) + case 'info': + return runLibraryInfo(reporter, target) + case 'list': + return runLibraryList(reporter) + case 'pin': + return runLibraryPin(reporter, args.positionals[1], args.positionals[2]) + case 'unpin': + return runLibraryUnpin(reporter, args.positionals[1], args.positionals[2]) + default: + return reporter.failure( + { + code: ErrorCode.InvalidArgument, + message: `library takes build, install, uninstall, info, list, pin or unpin — got "${subcommand ?? ''}".`, + }, + ExitCode.Usage, + ) + } +} + +/** + * Split `name` or `name@version`. + * + * `@` and not a `--version` flag: `--version` is a global boolean that prints + * the CLI's own version and exits before a command ever runs (`main.ts:179`). + * A leading `@` is part of the name, not a separator. + */ +export function splitLibraryRef(ref: string): { name: string; version?: string } { + const at = ref.lastIndexOf('@') + if (at <= 0) return { name: ref } + return { name: ref.slice(0, at), version: ref.slice(at + 1) } +} + +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, .lib, .library or .zip file.', + }, + ExitCode.Usage, + ) + } + + // `target_error`, not `invalid_argument`: the argument parsed, the module ran + // and refused. The exit code already said so. + const result = await new LibraryManagerModule().installFromFile(stlibPath) + if (!result.success) { + return reporter.failure({ code: ErrorCode.TargetError, message: result.error }, ExitCode.TargetError) + } + + // A ZIP installs several, and one failing does not stop the others. Report + // every outcome and let the exit code stay 0: libraries did land. + if ('entries' in result) { + return reporter.success({ ok: true, libraries: result.installed, failed: result.failed }, () => + [ + `Installed ${result.installed.length} librar${result.installed.length === 1 ? 'y' : 'ies'} from ${stlibPath}`, + ...result.installed.map((library) => ` ${library.name} ${library.version}`), + ...result.failed.map((entry) => ` skipped ${entry.path} — ${entry.error}`), + ].join('\n'), + ) + } + + if (result.canceled) { + return reporter.failure( + { code: ErrorCode.TargetError, 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() + // The Versions column only earns its width once something has more than one. + // JSON always carries the array, whatever the table shows. + const anyMultiVersion = installed.some((library) => (library.versions?.length ?? 0) > 1) + return reporter.success({ ok: true, libraries: installed }, () => { + if (installed.length === 0) return 'No libraries installed.' + const headers = anyMultiVersion ? ['Name', 'Version', 'Installed', 'Origin'] : ['Name', 'Version', 'Origin'] + return renderTable( + headers, + installed.map((library) => + anyMultiVersion + ? [library.name, library.version, (library.versions ?? [library.version]).join(', '), library.origin] + : [library.name, library.version, library.origin], + ), + ) + }) +} + +/** + * Print what a `.stlib` actually contains — pin names and types included. + * + * `listInstalled` carries identity and provenance only, so answering "did this + * pin change between versions" otherwise means unzipping the archive by hand. + */ +function runLibraryInfo(reporter: Reporter, ref: string | undefined): CliResult { + if (!ref) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library info needs a library name, or name@version.' }, + ExitCode.Usage, + ) + } + + const { name, version } = splitLibraryRef(ref) + const manager = new LibraryManagerModule() + + // Check the version before reading it. `readArchiveText` resolves through + // `resolveVersion`, which substitutes the newest when the wanted one is not + // installed — right for a compile, which reports the substitution, and wrong + // here: `info x@9.9.9` would print some other version's blocks as if they + // were 9.9.9's. + const row = manager.listInstalled().find((library) => library.name === name) + if (!row) { + return reporter.failure( + { code: ErrorCode.TargetError, message: `Library '${name}' is not installed.` }, + ExitCode.NotFound, + ) + } + const installedVersions = row.versions ?? [row.version] + if (version !== undefined && !installedVersions.includes(version)) { + return reporter.failure( + { + code: ErrorCode.TargetError, + message: `Library '${name}' version ${version} is not installed. Installed: ${installedVersions.join(', ')}.`, + }, + ExitCode.NotFound, + ) + } + + const text = manager.readArchiveText(name, version) + if (text === null) { + return reporter.failure( + { code: ErrorCode.TargetError, message: `Library '${name}' has no readable archive.` }, + ExitCode.NotFound, + ) + } + + let archive: StlibArchiveDTO + try { + archive = JSON.parse(text) as StlibArchiveDTO + } catch { + return reporter.failure( + { code: ErrorCode.TargetError, message: `Library '${name}' has an unreadable archive.` }, + ExitCode.TargetError, + ) + } + + const { manifest } = archive + + const payload = { + ok: true, + library: { + name: manifest.name, + displayName: manifest.displayName ?? null, + version: manifest.version, + namespace: manifest.namespace, + description: manifest.description ?? null, + bundled: row?.bundled ?? manifest.isBuiltin, + origin: row?.origin ?? null, + installedAt: row?.installedAt ?? null, + versions: row?.versions ?? [manifest.version], + functions: manifest.functions, + functionBlocks: manifest.functionBlocks, + types: manifest.types, + globalConstants: archive.globalConstants ?? {}, + sources: (archive.sources ?? []).map((file) => file.fileName), + resources: (archive.resources ?? []).map((file) => file.path), + }, + } + + return reporter.success(payload, () => renderLibraryInfo(payload.library)) +} + +function renderLibraryInfo(library: { + name: string + displayName: string | null + version: string + namespace: string + description: string | null + bundled: boolean + versions: string[] + functions: StlibArchiveDTO['manifest']['functions'] + functionBlocks: StlibArchiveDTO['manifest']['functionBlocks'] + types: StlibArchiveDTO['manifest']['types'] + globalConstants: Record + sources: string[] + resources: string[] +}): string { + const pins = (list: Array<{ name: string; type: string }>) => + list.length === 0 ? '—' : list.map((pin) => `${pin.name}: ${pin.type}`).join(', ') + + const lines = [ + `${library.displayName ?? library.name} ${library.version}${library.bundled ? ' (bundled)' : ''}`, + ` namespace: ${library.namespace}`, + ] + if (library.description) lines.push(` ${library.description}`) + if (library.versions.length > 1) lines.push(` installed: ${library.versions.join(', ')}`) + + if (library.functionBlocks.length > 0) { + lines.push('', `Function blocks (${library.functionBlocks.length})`) + for (const block of library.functionBlocks) { + lines.push(` ${block.name}${block.implementation ? ` [${block.implementation}]` : ''}`) + lines.push(` in: ${pins(block.inputs)}`) + lines.push(` out: ${pins(block.outputs)}`) + if (block.inouts.length > 0) lines.push(` inout: ${pins(block.inouts)}`) + } + } + + if (library.functions.length > 0) { + lines.push('', `Functions (${library.functions.length})`) + for (const fn of library.functions) { + const params = fn.parameters.map((p) => `${p.name}: ${p.type}`).join(', ') + lines.push(` ${fn.name}(${params}) : ${fn.returnType}${fn.variadic ? ' (variadic)' : ''}`) + } + } + + if (library.types.length > 0) { + lines.push('', `Data types (${library.types.length})`) + for (const type of library.types) { + lines.push(` ${type.name} ${type.kind}${type.baseType ? ` of ${type.baseType}` : ''}`) + } + } + + const constants = Object.keys(library.globalConstants) + if (constants.length > 0) lines.push('', `Global constants (${constants.length})`, ` ${constants.join(', ')}`) + if (library.sources.length > 0) + lines.push('', `Sources (${library.sources.length})`, ` ${library.sources.join(', ')}`) + if (library.resources.length > 0) + lines.push('', `Resources (${library.resources.length})`, ` ${library.resources.join(', ')}`) + + return lines.join('\n') +} + +async function runLibraryUninstall(args: ParsedArgs, reporter: Reporter, ref: string | undefined): Promise { + if (!ref) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library uninstall needs a library name, or name@version.' }, + ExitCode.Usage, + ) + } + + const { name, version } = splitLibraryRef(ref) + const manager = new LibraryManagerModule() + const row = manager.listInstalled().find((library) => library.name === name) + if (!row) { + return reporter.failure( + { code: ErrorCode.TargetError, message: `Library '${name}' is not installed.` }, + ExitCode.NotFound, + ) + } + + const installedVersions = row.versions ?? [row.version] + const removeAll = boolFlag(args, 'all') + + // Refuse rather than guess. Taking the newest silently is the one outcome + // nobody asks for, and it is unrecoverable. + if (!version && !removeAll && installedVersions.length > 1) { + return reporter.failure( + { + code: ErrorCode.InvalidArgument, + message: `Library '${name}' has ${installedVersions.length} versions installed (${installedVersions.join(', ')}). Name one as ${name}@, or pass --all.`, + }, + ExitCode.Usage, + ) + } + + const targets = version ? [version] : removeAll ? installedVersions : [installedVersions[0]] + const removed: string[] = [] + for (const target of targets) { + const result = manager.uninstall(name, target) + if (!result.success) { + return reporter.failure( + { code: ErrorCode.TargetError, message: result.error ?? `Could not uninstall ${name} ${target}.` }, + ExitCode.TargetError, + ) + } + removed.push(target) + } + + const remaining = manager.listInstalled().find((library) => library.name === name)?.versions ?? [] + return reporter.success({ ok: true, library: name, removed, remaining }, () => + [ + `Uninstalled ${name} ${removed.join(', ')}`, + remaining.length > 0 ? ` still installed: ${remaining.join(', ')}` : '', + ] + .filter(Boolean) + .join('\n'), + ) +} diff --git a/src/cli/compile/headless-bridge.ts b/src/cli/compile/headless-bridge.ts index 59f73f21e..44ce3d535 100644 --- a/src/cli/compile/headless-bridge.ts +++ b/src/cli/compile/headless-bridge.ts @@ -16,6 +16,8 @@ import type { CompileProgressChannel } from '@root/backend/editor/compiler/types import { LibraryManagerModule } from '@root/backend/editor/library-manager' import type { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import type { EnabledArchives, LibraryRef } from '../../middleware/shared/ports/library-types' + /** * A progress channel for a headless caller. * @@ -63,7 +65,7 @@ export interface HeadlessCompileBridge { * while the GUI attributed them correctly -- the kind of difference between * the two front ends this bridge exists to prevent. */ getRuntimeUsername: () => string | null - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives } /** @@ -96,6 +98,6 @@ export function createHeadlessCompileBridge(runtime: RuntimeApiClient | null): H getRuntimeUsername: () => runtime?.tokens.getUsername() ?? null, - loadEnabledArchives: (enabledNames) => libraries.loadEnabledArchives(enabledNames), + loadEnabledArchives: (refs) => libraries.loadEnabledArchives(refs), } } diff --git a/src/cli/main.ts b/src/cli/main.ts index 9b389f72f..e96b592f0 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 { runPackages } from './commands/packages' import { runDaemonFromStdin } from './daemon-entry' import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' @@ -80,6 +81,13 @@ 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 (.stlib, .lib, .library, or a .zip of them) + openplc-cli library uninstall [@] [--all] + openplc-cli library info [@] + openplc-cli library list + openplc-cli library pin @ + openplc-cli library unpin openplc-cli packages list (boards --target accepts, and their pins) openplc-cli packages install ... (same checks as the GUI: schema + signature) openplc-cli compile [--target ] [--port ] [--clean] @@ -206,6 +214,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/cli/project/load.ts b/src/cli/project/load.ts index 8f70b3fb1..b45127ea0 100644 --- a/src/cli/project/load.ts +++ b/src/cli/project/load.ts @@ -18,9 +18,11 @@ */ import { HardwareModule } from '@root/backend/editor/hardware' +import { LibraryManagerModule } from '@root/backend/editor/library-manager' import { ProjectService } from '@root/backend/editor/services' import { parseProjectFiles } from '@root/backend/shared/utils/parse-project-files' import { openPLCStoreBase } from '@root/frontend/store' +import { stlibsToSystemLibraries } from '@root/frontend/utils/stlib-to-system-library' import type { PLCProjectData } from '@root/middleware/shared/ports/types' export interface LoadedProject { @@ -39,6 +41,32 @@ export interface LoadedProject { export type LoadProjectResult = { success: true; project: LoadedProject } | { success: false; error: string } +/** + * Load the installed libraries into the store, returning any warning rather + * than throwing: a project that references no library still compiles, so a + * damaged library store must not stop the build. + * + * Two reads, as the renderer does: `loadAll` carries the POU lists the pool is + * built from, `listInstalled` carries the bundled flag the archive shape has no + * room for. + */ +function hydrateLibraries(): string[] { + try { + const libraries = new LibraryManagerModule() + const actions = openPLCStoreBase.getState().libraryActions + actions.setSystemLibraries(stlibsToSystemLibraries(libraries.loadAll())) + actions.setBundledLibraryNames( + libraries + .listInstalled() + .filter((library) => library.bundled) + .map((library) => library.name), + ) + return [] + } catch (err) { + return [`warning: could not read the installed libraries: ${err instanceof Error ? err.message : String(err)}`] + } +} + export async function loadProject(projectPath: string): Promise { // The main process's own reader, so the CLI sees exactly the file set the // GUI sees — including the defaults it synthesises for missing device files. @@ -72,6 +100,13 @@ export async function loadProject(projectPath: string): Promise({ const BLOCK_CORNER_RADIUS = 6 const EXECUTION_ORDER_BADGE_INSET = BLOCK_CORNER_RADIUS / 2 +/** + * An installed library's POU in the shape the node rebuild reads. + * + * The rebuild was written for a POU the project owns, which keeps its pins under + * `interface.variables` and its kind under `pouType`. A library POU carries the + * same facts under different names, so it is translated here rather than the + * rebuild being taught about two shapes. + */ +function libraryPouAsProjectPou( + variant: { name?: string }, + systemLibraries: SystemLibrary[], + projectPous: Array<{ name: string }>, +) { + const libraryPou = findLibraryPou( + variant as BlockVariant, + systemLibraries, + projectPous.map((pou) => pou.name), + ) + if (!libraryPou) return undefined + return { + name: libraryPou.name, + documentation: libraryPou.documentation, + pouType: libraryPou.type, + interface: { + // `id` is how a project POU's pin is tracked across a rename; a library + // pin has no such identity, and the remap falls back to the name. + variables: libraryPou.variables.map((pin) => ({ ...pin, id: undefined as string | undefined })), + // A library function returns through its OUT pin. + returnType: libraryPou.variables.find((pin) => pin.name === 'OUT')?.type?.value ?? '', + }, + } +} + const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block const pouName = useBoundPou() @@ -468,6 +504,21 @@ const Block = (block: BlockProps) => { const variable = variables.selected if (!variable) { + // A qualified name is a global variable list member (`NET.node`), which + // is not in this POU's interface under any spelling. Ask the LSP, which + // knows the lists, instead of calling it wrong. + const instanceName = (node.data as BasicNodeData).variable?.name ?? '' + if (instanceName.includes('.')) { + const blockTypeName = (node.data as BlockNodeData).variant.name + let cancelled = false + void isBlockInstanceInScope(pouName, instanceName, blockTypeName).then((inScope) => { + // `undefined` means the LSP could not answer; leave the block alone. + if (!cancelled && inScope !== undefined) setWrongVariable(!inScope) + }) + return () => { + cancelled = true + } + } setWrongVariable(true) return } @@ -616,9 +667,13 @@ const Block = (block: BlockProps) => { if (!variant) return const libMatch = libraries.user.find((lib) => lib.name === variant.name && lib.type === variant.type) - if (!libMatch) return + const projectPou = libMatch ? pous.find((pou) => pou.name === libMatch.name) : undefined - const libPou = pous.find((pou) => pou.name === libMatch.name) + // A block out of an installed library has no POU in the project, so its + // interface comes from the library instead. The rebuild below REPLACES the + // node rather than editing it, which is what a block growing a pin needs: + // the canvas keeps the handles it registered against the old one. + const libPou = projectPou ?? libraryPouAsProjectPou(variant, libraries.system, pous) if (!libPou) return const blockVariant = node.data.variant as BlockVariant diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 32ea9ae99..82a8601b5 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -1,15 +1,18 @@ +import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import type { PLCVariable } from '../../../../../middleware/shared/ports' import { PLCPou } from '../../../../../middleware/shared/ports' import { RefreshIcon } from '../../../../assets/icons/interface/Refresh' +import { isBlockInstanceInScope } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { LibraryState } from '../../../../store/slices/library' import { checkVariableName } from '../../../../store/slices/project/validation/variables' import { cn } from '../../../../utils/cn' import { legacyInOutSourcePinIds } from '../../../../utils/graphical/in-out-pin-rules' import { isLegalIdentifier } from '../../../../utils/keywords' +import { findLibraryPou } from '../../../../utils/PLC/library-block-divergence' import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundEditorModel, useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { updateDiagramElementsPosition } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram' @@ -414,6 +417,39 @@ export const BlockNodeElement = ({ ) } +/** + * An installed library's POU in the shape the node rebuild reads. + * + * The rebuild was written for a POU the project owns, which keeps its pins under + * `interface.variables` and its kind under `pouType`. A library POU carries the + * same facts under different names, so it is translated here rather than the + * rebuild being taught about two shapes. + */ +function libraryPouAsProjectPou( + variant: { name?: string }, + systemLibraries: SystemLibrary[], + projectPous: Array<{ name: string }>, +) { + const libraryPou = findLibraryPou( + variant as BlockVariant, + systemLibraries, + projectPous.map((pou) => pou.name), + ) + if (!libraryPou) return undefined + return { + name: libraryPou.name, + documentation: libraryPou.documentation, + pouType: libraryPou.type, + interface: { + // `id` is how a project POU's pin is tracked across a rename; a library + // pin has no such identity, and the remap below falls back to the name. + variables: libraryPou.variables.map((pin) => ({ ...pin, id: undefined as string | undefined })), + // A library function returns through its OUT pin. + returnType: libraryPou.variables.find((pin) => pin.name === 'OUT')?.type?.value ?? '', + }, + } +} + const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block @@ -558,6 +594,21 @@ const Block = (block: BlockProps) => { const variable = freshVariables.selected if (!variable) { + // A qualified name is a global variable list member (`NET.node`), which + // is not in this POU's interface under any spelling. Ask the LSP, which + // knows the lists, instead of calling it wrong. + const instanceName = data.variable.name + if (instanceName.includes('.')) { + const blockTypeName = (freshNode.data as BlockNodeData).variant.name + let cancelled = false + void isBlockInstanceInScope(pouName, instanceName, blockTypeName).then((inScope) => { + // `undefined` means the LSP could not answer; leave the block alone. + if (!cancelled && inScope !== undefined) setWrongVariable(!inScope) + }) + return () => { + cancelled = true + } + } setWrongVariable(true) return } @@ -722,9 +773,14 @@ const Block = (block: BlockProps) => { if (!variant) return const libMatch = libraries.user.find((lib) => lib.name === variant.name && lib.type === variant.type) - if (!libMatch) return - - const libPou = pous.find((pou) => pou.name === libMatch.name) + const projectPou = libMatch ? pous.find((pou) => pou.name === libMatch.name) : undefined + + // A block out of an installed library has no POU in the project, so its + // interface comes from the library instead. Everything below rebuilds the + // node from that interface and re-points the edges at it; a block growing a + // pin needs the node REPLACED, not edited, because the canvas keeps the + // handles it registered against the old one. + const libPou = projectPou ?? libraryPouAsProjectPou(variant, libraries.system, pous) if (!libPou) return const blockVariant = node.data.variant as BlockVariant 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..d45f54edf --- /dev/null +++ b/src/frontend/components/_atoms/string-length-menu-item/index.tsx @@ -0,0 +1,83 @@ +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. + // + // Enter is the exception: it is let through to the item, whose own + // `onSelect` applies the type and closes the menu. Applying it here + // instead left the menu open, because nothing but a Radix selection + // closes it. + onKeyDown={(e) => { + if (e.key === 'Enter') return + e.stopPropagation() + }} + 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 67e030362..e424cb0a9 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': , 'persistent-storage': , // Same icon as the tree node for this screen, so the tab and the tree @@ -98,6 +99,7 @@ const Tab = (props: ITabProps) => { | 'ethercat-device' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'persistent-storage' | 'diff-viewer' = 'il' @@ -139,6 +141,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..5aa5e3295 100644 --- a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx +++ b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx @@ -1,9 +1,11 @@ import * as PrimitiveDropdown from '@radix-ui/react-dropdown-menu' import _ from 'lodash' -import { useState } from 'react' +import { useEffect, 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,18 @@ export const TypeDropdownSelector = ({ 'base-type': '', 'user-data-type': '', }) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) + + // A table recycles this component across rows, so the seed cannot be a + // mount-time value: the box would keep the length the previous row declared + // and apply it to this one. + useEffect(() => setStringLengths(seedStringLengths(value)), [value]) return (
- {value ? _.upperCase(value) : 'Select...'} + {value ? value.toUpperCase() : 'Select...'}
@@ -72,17 +80,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 003c1e645..b69966cc9 100644 --- a/src/frontend/components/_features/[workspace]/build-options/index.tsx +++ b/src/frontend/components/_features/[workspace]/build-options/index.tsx @@ -19,6 +19,14 @@ type BuildOptionsPopoverProps = { uploadAvailable: boolean /** Tooltip shown when an upload-bearing option is disabled. */ uploadDisabledReason: string + /** + * Render the library-build option set instead of the program one: + * "Build" and "Clean build" — no upload variants. The library + * build pipeline emits `build-only` for a normal build and + * `clean-upload` for a clean build (the enum is reused so the + * popover stays a single component). + */ + libraryMode?: boolean onSelect: (option: BuildOption) => void } @@ -77,6 +85,7 @@ export const BuildOptionsPopover = ({ triggerTooltip, uploadAvailable, uploadDisabledReason, + libraryMode, onSelect, }: BuildOptionsPopoverProps): ReactNode => { const [open, setOpen] = useState(false) @@ -118,27 +127,48 @@ export const BuildOptionsPopover = ({ alignOffset={-4} className='box z-50 flex h-fit w-[230px] flex-col gap-1 rounded-lg bg-white p-2 dark:bg-neutral-950' > - choose('build-only')} - /> - choose('build-upload')} - /> - choose('clean-upload')} - /> + {libraryMode ? ( + <> + choose('build-only')} + /> + choose('clean-upload')} + /> + + ) : ( + <> + choose('build-only')} + /> + choose('build-upload')} + /> + 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..af5a15d2e --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx @@ -0,0 +1,285 @@ +/** + * 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' + +/** Toast text for a rejected port call. */ +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)) + +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 + // Each resource method is independently optional on the port, so removal is + // asked about separately — the controls must not offer what `handleRemove` + // would silently decline. + const canRemove = canManage && 'removeLibraryResource' in projectPort + + // Every port call below can reject — the IPC channel drops, the filesystem + // refuses — and each caller hands the promise to `void`. Without a catch here + // the rejection is unhandled and the user is told nothing at all, so the + // failure is reported on the same toast as a returned error. + const refresh = useCallback(async () => { + if (!projectPort.listLibraryResources) return + try { + 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))) + } catch (error) { + toast({ title: 'Could not read resources', description: errorText(error), variant: 'fail' }) + } + }, [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' }) + } catch (error) { + toast({ title: 'Could not add the folder', description: errorText(error), variant: 'fail' }) + } 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() + } catch (error) { + toast({ title: `Could not remove ${name}`, description: errorText(error), variant: 'fail' }) + } 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) => ( +
+ + + {!canRemove ? null : 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 56f67978d..d502972e3 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -13,6 +13,7 @@ import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' import { useDeviceLicense } from '../../../../../../hooks/use-device-license' import { useRuntimeConnect } from '../../../../../../hooks/use-runtime-connect' 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' @@ -330,21 +331,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/graphical/FBD/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx index d7d83d07f..8d2b670e1 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react' import { useOpenPLCStore } from '../../../../../../store' import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback' import { hasLegacyInOutOutputHandle } from '../../../../../../utils/graphical/in-out-pin-rules' +import { findLibraryPou, libraryVariantDiverges } from '../../../../../../utils/PLC/library-block-divergence' import { BlockNodeData } from '../../../../../_atoms/graphical-editor/fbd/block' import { BlockVariant } from '../../../../../_atoms/graphical-editor/types/block' import { FBDBody } from '../../../../../_molecules/graphical-editor/fbd' @@ -20,6 +21,7 @@ export default function FbdEditor() { const fbdFlows = useOpenPLCStore((state) => state.fbdFlows) const pous = useOpenPLCStore((state) => state.project.data.pous) const userLibraries = useOpenPLCStore((state) => state.libraries.user) + const systemLibraries = useOpenPLCStore((state) => state.libraries.system) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const flow = fbdFlows.find((flow) => flow.name === pouName) @@ -35,10 +37,22 @@ export default function FbdEditor() { if (!variant) continue const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) - if (!libMatch) continue - - const originalPou = pous.find((pou) => pou.name === libMatch.name) - if (!originalPou) continue + const originalPou = libMatch ? pous.find((pou) => pou.name === libMatch.name) : undefined + + if (!originalPou) { + // Not a POU this project owns, so it came out of a library. The + // load-time re-stamp reports a pin the library added and cannot draw + // it; this is what puts the badge there to apply it. + const libraryPou = findLibraryPou( + variant, + systemLibraries, + pous.map((pou) => pou.name), + ) + if (libraryPou && libraryVariantDiverges(variant, libraryPou)) { + divergences.push(node.id) + } + continue + } const originalVariables = originalPou.interface?.variables ?? [] const originalInOut = originalVariables.filter((variable) => @@ -81,7 +95,7 @@ export default function FbdEditor() { } return divergences.length > 0 ? divergences : EMPTY_DIVERGENCES - }, [flow?.rung.nodes, userLibraries, pous]) + }, [flow?.rung.nodes, userLibraries, systemLibraries, pous]) /** * Queue the flow → project JSON write-back. The scheduler debounces it diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index 956df2eef..816da862c 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -26,6 +26,7 @@ import { RungLadderState } from '../../../../../../store/slices/ladder' import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback' import { cn } from '../../../../../../utils/cn' import { hasLegacyInOutOutputHandle } from '../../../../../../utils/graphical/in-out-pin-rules' +import { findLibraryPou, libraryVariantDiverges } from '../../../../../../utils/PLC/library-block-divergence' import { BlockNode, BlockNodeData } from '../../../../../_atoms/graphical-editor/ladder/block' import { CoilNode } from '../../../../../_atoms/graphical-editor/ladder/coil' import { ContactNode } from '../../../../../_atoms/graphical-editor/ladder/contact' @@ -55,6 +56,7 @@ export default function LadderEditor() { const pous = useOpenPLCStore((state) => state.project.data.pous) const closeModal = useOpenPLCStore((state) => state.modalActions.closeModal) const userLibraries = useOpenPLCStore((state) => state.libraries.user) + const systemLibraries = useOpenPLCStore((state) => state.libraries.system) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const { captureAndPush } = usePouSnapshot() @@ -78,10 +80,22 @@ export default function LadderEditor() { if (!variant) continue const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) - if (!libMatch) continue - - const originalPou = pous.find((pou) => pou.name === libMatch.name) - if (!originalPou) continue + const originalPou = libMatch ? pous.find((pou) => pou.name === libMatch.name) : undefined + + if (!originalPou) { + // Not a POU this project owns, so it came out of a library. The + // load-time re-stamp reports a pin the library added and cannot draw + // it; this is what puts the badge there to apply it. + const libraryPou = findLibraryPou( + variant, + systemLibraries, + pous.map((pou) => pou.name), + ) + if (libraryPou && libraryVariantDiverges(variant, libraryPou)) { + divergences.push(`${rung.id}:${node.id}`) + } + continue + } const originalVariables = originalPou.interface?.variables ?? [] const originalInOut = originalVariables.filter((variable) => @@ -126,7 +140,7 @@ export default function LadderEditor() { } return divergences.length > 0 ? divergences : EMPTY_DIVERGENCES - }, [flow?.rungs, userLibraries, pous]) + }, [flow?.rungs, userLibraries, systemLibraries, pous]) const scrollableRef = useRef(null) useEffect(() => { diff --git a/src/frontend/components/_features/[workspace]/editor/library-manager/__tests__/project-libraries-tab.test.tsx b/src/frontend/components/_features/[workspace]/editor/library-manager/__tests__/project-libraries-tab.test.tsx new file mode 100644 index 000000000..39311842a --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/library-manager/__tests__/project-libraries-tab.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { useOpenPLCStore } from '@root/frontend/store' +import type { InstalledLibrary } from '@root/middleware/shared/ports/library-types' + +import { ProjectLibrariesTab } from '../project-libraries-tab' + +const row = (over: Partial = {}): InstalledLibrary => ({ + name: 'libtest-basic', + version: '0.2.0', + bundled: false, + installedAt: '2026-01-01T00:00:00.000Z', + origin: 'stlib', + versions: ['0.2.0', '0.1.0'], + ...over, +}) + +function seed({ pinned, outdated = true }: { pinned: string; outdated?: boolean }) { + useOpenPLCStore.setState((state) => ({ + ...state, + project: { + ...state.project, + data: { ...state.project.data, libraries: [{ name: 'libtest-basic', version: pinned }] }, + }, + enabledLibraries: ['libtest-basic'], + missingLibraries: [], + outdatedLibraries: outdated ? [{ name: 'libtest-basic', pinned, available: ['0.2.0', '0.1.0'] }] : [], + })) +} + +describe('ProjectLibrariesTab', () => { + it('shows the version the project pins, not the newest installed', () => { + seed({ pinned: '0.1.0' }) + render() + + expect(screen.getByRole('combobox', { name: 'Version of libtest-basic' }).textContent).toContain('v0.1.0') + }) + + it('offers a way into the update dialog when a newer version is installed', async () => { + const user = userEvent.setup() + seed({ pinned: '0.1.0' }) + render() + + await user.click(screen.getByRole('button', { name: /newer version installed/i })) + + expect(useOpenPLCStore.getState().modals['library-updates']?.open).toBe(true) + }) + + it('says nothing about updates when the project is on the newest', () => { + seed({ pinned: '0.2.0', outdated: false }) + render() + + expect(screen.queryByRole('button', { name: /newer version installed/i })).toBeNull() + }) + + it('names a pinned version that is not installed rather than showing a wrong one', () => { + seed({ pinned: '9.9.9', outdated: false }) + render() + + expect(screen.getByRole('combobox', { name: 'Version of libtest-basic' }).textContent).toContain('not installed') + }) + + it('shows how many versions are available on a library not yet added', () => { + seed({ pinned: '0.1.0', outdated: false }) + useOpenPLCStore.setState((state) => ({ ...state, enabledLibraries: [] })) + render() + + expect(screen.getByText(/2 versions/)).toBeTruthy() + }) +}) 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..bc6fb8bc3 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 @@ -26,10 +26,21 @@ import { MagnifierIcon } from '@root/frontend/assets/icons/interface/Magnifier' import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' import { useOpenPLCStore } from '@root/frontend/store' +import { cn } from '@root/frontend/utils/cn' import type { InstalledLibrary } from '@root/middleware/shared/ports/library-types' import { useMemo, useState } from 'react' +import { reconcilePlacedBlocks } from './reconcile-placed-blocks' + +/** Dropdown row styling, shared by the two lists below. */ +const SELECT_ITEM = cn( + 'data-[state=checked]:[&:not(:hover)]:bg-neutral-100 data-[state=checked]:dark:[&:not(:hover)]:bg-neutral-900', + 'flex w-full cursor-pointer items-center justify-start px-2 py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-800', +) +const SELECT_ITEM_TEXT = 'text-start font-caption text-xs font-normal text-neutral-700 dark:text-neutral-100' + interface ProjectLibrariesTabProps { installed: InstalledLibrary[] } @@ -39,6 +50,12 @@ const ProjectLibrariesTab = ({ installed }: ProjectLibrariesTabProps) => { const missingLibraries = useOpenPLCStore((s) => s.missingLibraries) const enableLibrary = useOpenPLCStore((s) => s.libraryActions.enableLibrary) const disableLibrary = useOpenPLCStore((s) => s.libraryActions.disableLibrary) + const setLibraryVersion = useOpenPLCStore((s) => s.libraryActions.setLibraryVersion) + // The version each enabled library is pinned to, which is not necessarily + // the newest installed one. + const pinnedVersions = useOpenPLCStore((s) => s.project?.data?.libraries) + const outdated = useOpenPLCStore((s) => s.outdatedLibraries) + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) const [availableFilter, setAvailableFilter] = useState('') @@ -107,6 +124,16 @@ const ProjectLibrariesTab = ({ installed }: ProjectLibrariesTabProps) => { : `${bundled.length} bundled, ${enabled.length} added.` } > + {outdated.length > 0 && ( + + )} {missingLibraries.length > 0 && (
Missing libraries: @@ -138,6 +165,13 @@ const ProjectLibrariesTab = ({ installed }: ProjectLibrariesTabProps) => { action='remove' onAction={() => disableLibrary(lib.name)} actionTitle='Remove from project' + pinned={pinnedVersions?.find((ref) => ref.name === lib.name)?.version} + onPin={(version) => { + setLibraryVersion(lib.name, version) + // Same as taking the update from the dialog: the pin and the + // placed blocks move together or they disagree. + reconcilePlacedBlocks() + }} /> ))} @@ -179,6 +213,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}
} @@ -196,22 +233,65 @@ function LibraryRow({ action, onAction, actionTitle, + pinned, + onPin, }: { lib: InstalledLibrary action: 'add' | 'remove' | 'locked' onAction?: () => void actionTitle?: string + /** Version this project uses, when it differs from the newest installed. */ + pinned?: string + onPin?: (version: string) => void }) { + const versions = lib.versions ?? [lib.version] + const shown = pinned ?? lib.version + // A pin can name a version this machine does not have. Offer it anyway, so + // the control shows what the project actually records. + const missingPin = !versions.includes(shown) + const showPicker = !!onPin && (versions.length > 1 || missingPin) return ( -
+
{lib.displayName ?? lib.name} - - v{lib.version} - {lib.bundled ? ' · bundled' : lib.origin === 'codesys' ? ' · CODESYS' : ''} - + {showPicker ? ( + + ) : ( + + v{shown} + {versions.length > 1 ? ` · ${versions.length} versions` : ''} + {lib.bundled ? ' · bundled' : lib.origin === 'codesys' ? ' · CODESYS' : ''} + + )}
{action === 'add' && (
@@ -198,7 +240,30 @@ const SystemLibrariesTab = ({ installed, onRefresh }: SystemLibrariesTabProps) =
- + {selectedVersions.length > 1 ? ( +
+ + Versions + + {selectedVersions.map((version) => ( +
+ + {version} + + +
+ ))} +
+ ) : ( + + )} { ) } + // 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 7d302c3f2..c6a13152a 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 & { @@ -75,6 +77,7 @@ const SelectableTypeCell = ({ 'user-data-type': '', }) const [inputFilter, setInputFilter] = useState('') + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const variableName = table.options.data[index].name @@ -123,7 +126,7 @@ const SelectableTypeCell = ({ ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : cellValue.toUpperCase()}
@@ -167,17 +170,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..2d997f3c2 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.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 d51c3d107..bf3e1607a 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -470,6 +470,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'ethercatDevice' | 'softMotionDrive' | 'libraryManifest' + | 'buildSettings' | 'userManagement' | 'persistentStorage' leafType: WorkspaceProjectTreeLeafType @@ -512,6 +513,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 }, persistentStorage: { LeafIcon: ConfigIcon }, } @@ -562,6 +566,19 @@ const ProjectTreeLeaf = ({ // A SoftMotion drive is an EtherCAT child device too (cia402.enabled) — it // shares every EtherCAT device action (rename/delete), just a distinct icon. const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice' || leafLang === 'softMotionDrive', [leafLang]) + // A fixed leaf names a panel, not a file, so it has nothing to rename or + // delete. One predicate for both the actions popover and the double-click, + // which used to disagree — the popover was hidden while a double-click still + // opened a rename box that `handleRenameFile` could only refuse. + const isFixedLeaf = useMemo( + () => + leafLang === 'devPin' || + leafLang === 'devConfig' || + leafLang === 'buildSettings' || + leafLang === 'userManagement' || + leafLang === 'persistentStorage', + [leafLang], + ) const { LeafIcon } = LeafSources[leafLang] const { file: associatedFile } = getFile({ name: label || '' }) @@ -859,16 +876,13 @@ const ProjectTreeLeaf = ({ name === label && 'font-medium text-neutral-1000 dark:text-white', isUnsaved(associatedFile) && 'italic', )} - onDoubleClick={() => !isDebuggerVisible && setIsEditing(true)} + onDoubleClick={() => !isDebuggerVisible && !isFixedLeaf && setIsEditing(true)} > )} - {leafLang === 'devPin' || - leafLang === 'devConfig' || - leafLang === 'userManagement' || - leafLang === 'persistentStorage' ? null : ( + {isFixedLeaf ? 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] @@ -270,11 +273,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.toUpperCase()}
@@ -321,17 +326,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 484ca95d6..cd399375b 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 { isNativeScreenAvailable } from '../../../utils/native-screens' import { useToast } from '../../_features/[app]/toast/use-toast' @@ -217,18 +221,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/__tests__/library-updates-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/library-updates-modal.test.tsx new file mode 100644 index 000000000..a429b4796 --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/library-updates-modal.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { useOpenPLCStore } from '@root/frontend/store' + +import { LibraryUpdatesModal } from '../library-updates-modal' + +/** One library the project pins below a version already installed. */ +function seed(pinned = '0.1.0', available = ['0.2.0', '0.1.0']) { + useOpenPLCStore.setState((state) => ({ + ...state, + project: { + ...state.project, + data: { ...state.project.data, libraries: [{ name: 'libtest-basic', version: pinned }] }, + }, + installedLibraries: available.map( + (version) => ({ name: 'libtest-basic', author: '', version, stPath: '', cPath: '', pous: [] }) as never, + ), + outdatedLibraries: [{ name: 'libtest-basic', pinned, available }], + })) + useOpenPLCStore.getState().modalActions.openModal('library-updates') +} + +const pinnedVersion = () => + useOpenPLCStore.getState().project.data.libraries?.find((l) => l.name === 'libtest-basic')?.version + +describe('LibraryUpdatesModal', () => { + it('lists the outdated library with the version in use', () => { + seed() + render() + + expect(screen.getByText('Library updates')).toBeTruthy() + expect(screen.getByText('libtest-basic')).toBeTruthy() + expect(screen.getByText('in use: v0.1.0')).toBeTruthy() + }) + + it('proposes the newest version and offers it as the action', () => { + seed() + render() + + // The trigger shows the proposed action, the way CODESYS names one. + expect(screen.getByRole('combobox', { name: 'Version for libtest-basic' }).textContent).toContain('Use v0.2.0') + expect((screen.getByRole('button', { name: 'Update 1' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('applies the pin and closes when Update is pressed', async () => { + const user = userEvent.setup() + seed() + render() + + await user.click(screen.getByRole('button', { name: 'Update 1' })) + + expect(pinnedVersion()).toBe('0.2.0') + expect(useOpenPLCStore.getState().modals['library-updates']?.open).toBe(false) + }) + + it('changes nothing when dismissed', async () => { + const user = userEvent.setup() + seed() + render() + + await user.click(screen.getByRole('button', { name: 'Not now' })) + + expect(pinnedVersion()).toBe('0.1.0') + }) + + it('offers nothing to change when every row is already on its pinned version', () => { + // `available[0]` equals the pin, so the proposed action is "keep". + seed('0.2.0', ['0.2.0']) + render() + + expect((screen.getByRole('button', { name: 'Nothing to change' }) as HTMLButtonElement).disabled).toBe(true) + }) +}) 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..bb44c2d4c 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(() => { @@ -46,6 +50,7 @@ const CreateGraphicalVariableModal = ({ setName(data.name) setVariableClass('local') setTypeValue(data.suggestedType.value) + setStringLength('') }, [isOpen, data.name, data.suggestedType.value]) /** @@ -71,16 +76,21 @@ 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 +164,23 @@ const CreateGraphicalVariableModal = ({ ))}
+ + {lengthIsOffered && ( +
+ + setStringLength(event.target.value)} + className={cn(inputClass, !lengthIsValid && 'border-red-500 text-red-500')} + /> +
+ )}
diff --git a/src/frontend/components/_organisms/modals/library-updates-modal.tsx b/src/frontend/components/_organisms/modals/library-updates-modal.tsx new file mode 100644 index 000000000..3f1491646 --- /dev/null +++ b/src/frontend/components/_organisms/modals/library-updates-modal.tsx @@ -0,0 +1,139 @@ +/** + * Library updates modal — offered when the open project pins a library to a + * version older than one already installed. + * + * Each row proposes the newest installed version; setting a row back to the + * version the project already pins keeps it. Applying repins the project and + * re-stamps the placed blocks, so one update reaches every call at once. + */ + +import { useMemo, useState } from 'react' + +import { useOpenPLCStore } from '../../../store' +import { cn } from '../../../utils/cn' +import { Select, SelectContent, SelectItem, SelectTrigger } from '../../_atoms/select' +import { reconcilePlacedBlocks } from '../../_features/[workspace]/editor/library-manager/reconcile-placed-blocks' +import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' + +const LibraryUpdatesModal = () => { + const isOpen = useOpenPLCStore((state) => state.modals['library-updates']?.open ?? false) + const outdated = useOpenPLCStore((state) => state.outdatedLibraries) + const setLibraryVersion = useOpenPLCStore((state) => state.libraryActions.setLibraryVersion) + const closeModal = useOpenPLCStore((state) => state.modalActions.closeModal) + const onOpenChange = useOpenPLCStore((state) => state.modalActions.onOpenChange) + + /** Newest first, so index 0 is the proposed action for every row. */ + const newest = useMemo( + () => Object.fromEntries(outdated.map((library) => [library.name, library.available[0]])), + [outdated], + ) + const [choices, setChoices] = useState>(newest) + + const selected = (name: string) => choices[name] ?? newest[name] + const pending = outdated.filter((library) => selected(library.name) !== library.pinned) + + const handleApply = () => { + for (const library of pending) setLibraryVersion(library.name, selected(library.name)) + if (pending.length > 0) reconcilePlacedBlocks() + closeModal() + } + + return ( + { + if (!open) closeModal() + onOpenChange('library-updates', open) + }} + > + + Library updates + +

+ {outdated.length === 1 + ? 'This project uses a library with a newer version installed.' + : `This project uses ${outdated.length} libraries with newer versions installed.`}{' '} + Choose the version each one should use; leaving a row on the version it already uses keeps it. +

+ +
    + {outdated.map((library) => ( +
  • + + + {library.name} + + in use: v{library.pinned} + + +
  • + ))} +
+ +
+ +
+ + +
+
+
+
+ ) +} + +export { LibraryUpdatesModal } diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 5f18661ec..2521d06bf 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -31,7 +31,6 @@ import { isOpenPLCRuntimeTarget } from '../../../utils/device' import { onDeviceFlashRequest } from '../../../utils/device-connect-events' import { getErrorMessage } from '../../../utils/get-error-message' import { type BuildOption, BuildOptionsPopover } from '../../_features/[workspace]/build-options' -import { BuildLibraryButton } from '../../_molecules/workspace-activity-bar/default/build-library' import { ChatButton } from '../../_molecules/workspace-activity-bar/default/chat' import { DebuggerButton } from '../../_molecules/workspace-activity-bar/default/debugger' import { PlayButton } from '../../_molecules/workspace-activity-bar/default/play' @@ -587,59 +586,73 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Build Library (.stlib) // --------------------------------------------------------------------------- - const handleBuildLibrary = useCallback(async () => { - if (isCompiling) return - - // Reveal the console and re-attach to the tail (see handleBuild). - requestConsoleFollow() + const handleBuildLibrary = useCallback( + async (overrides?: { cleanBuild?: boolean }) => { + if (isCompiling) return - // Always save before building. The manifest tab and any POU - // bodies may have edits the workspace-level `editingState` - // doesn't track (each editor manages its own dirty flag against - // its file-slice entry), and the build pipeline reads everything - // off disk — `library.json`, `pous/**`, and the rest — so a - // stale on-disk copy would compile from the previous session's - // content. `executeSaveProject` is the same full-project save - // the PLC build invokes; it walks every file the project owns - // and flushes the in-memory buffer to disk before the build - // starts. - const saved = await executeSave() - if (!saved) return - - if (!compiler.compileLibrary) { - addLog({ - level: 'error', - message: 'Current platform does not implement library builds.', - }) - return - } + // Reveal the console and re-attach to the tail (see handleBuild). + requestConsoleFollow() - setIsCompiling(true) - addLog({ level: 'info', message: 'Library build started' }) + // Always save before building. The manifest tab and any POU + // bodies may have edits the workspace-level `editingState` + // doesn't track (each editor manages its own dirty flag against + // its file-slice entry), and the build pipeline reads everything + // off disk — `library.json`, `pous/**`, and the rest — so a + // stale on-disk copy would compile from the previous session's + // content. `executeSaveProject` is the same full-project save + // the PLC build invokes; it walks every file the project owns + // and flushes the in-memory buffer to disk before the build + // starts. + const saved = await executeSave() + if (!saved) return - try { - const result = await compiler.compileLibrary({ projectData, projectPath: projectMeta.path }, (event) => { - if (!event.message) return + if (!compiler.compileLibrary) { addLog({ - level: event.level === 'error' || event.stage === 'error' ? 'error' : 'info', - message: event.message, + level: 'error', + message: 'Current platform does not implement library builds.', }) + return + } + + setIsCompiling(true) + addLog({ + level: 'info', + message: overrides?.cleanBuild ? 'Library build started (clean)' : 'Library build started', }) - if (!result.success) { + + try { + const result = await compiler.compileLibrary( + { projectData, projectPath: projectMeta.path, cleanBuild: overrides?.cleanBuild ?? false }, + (event) => { + if (!event.message) return + addLog({ + level: event.level === 'error' || event.stage === 'error' ? 'error' : 'info', + message: event.message, + }) + }, + ) + if (!result.success) { + addLog({ + level: 'error', + message: result.error ?? 'Library build failed.', + }) + } else if (result.verification && !result.verification.success) { + addLog({ + level: 'warning', + message: `Library built, but verification reported: ${result.verification.message ?? 'unknown'}`, + }) + } + } catch (err) { addLog({ level: 'error', - message: result.error ?? 'Library build failed.', + message: `Library build error: ${getErrorMessage(err)}`, }) + } finally { + setIsCompiling(false) } - } catch (err) { - addLog({ - level: 'error', - message: `Library build error: ${getErrorMessage(err)}`, - }) - } finally { - setIsCompiling(false) - } - }, [compiler, projectData, projectMeta, addLog, isCompiling, canEdit, executeSave, requestConsoleFollow]) + }, + [compiler, projectData, projectMeta, addLog, isCompiling, executeSave, requestConsoleFollow], + ) // --------------------------------------------------------------------------- // Debug Library — run the library's blocks on the simulator @@ -1361,13 +1374,28 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa the debugger attached — the only way to see a library actually execute. */} {projectCaps.hasLibraryBuild && ( - - void handleBuildLibrary()} - disabled={isCompiling} - className={cn(isCompiling && disabledButtonClass)} - /> - + // No outer `TooltipSidebarWrapperButton`: `BuildOptionsPopover` + // already renders its own Radix tooltip via `triggerTooltip`, + // and the wrapper's tooltip persisted on top of the popover + // contents once the menu opened (PLC build button doesn't wrap + // either — same idiom here for consistency). + { + switch (option) { + case 'build-only': + void handleBuildLibrary({ cleanBuild: false }) + break + case 'clean-upload': + void handleBuildLibrary({ cleanBuild: true }) + break + } + }} + /> )} {projectCaps.hasLibraryDebug && ( { } = useOpenPLCStore() const isMonacoFocused: boolean = useOpenPLCStore((state) => state.isMonacoFocused) const selectedProjectTreeLeaf = useOpenPLCStore((state) => state.workspace.selectedProjectTreeLeaf) - const pendingRecentProjectRef = useRef(null) + const pendingRecentProjectRef = useRef(null) const executeSave = useCallback(() => executeSaveProject(projectPort, capabilities), [projectPort, capabilities]) @@ -149,28 +149,49 @@ const AcceleratorHandler = () => { }, [editingState, accelerator, openModal, projectPort, handleOpenProjectResponse]) /** - * Open recent project (editor-specific — data passed via IPC accelerator) + * Open recent project (editor-specific — the PATH arrives via IPC). + * + * Opening goes through `projectPort.openProjectByPath` like the start + * screen, the recents list and File → Open, so the files are parsed into the + * shape the store expects and a project that has moved raises a toast. The + * main process used to read the project and send its response, which was + * neither unwrapped nor parsed and crashed the renderer on every recent. */ + const openRecentByPath = useCallback( + async (projectPath: string) => { + const result = await projectPort.openProjectByPath(projectPath) + if (result.success && result.data) { + handleOpenProjectResponse(result.data) + return + } + toast({ + title: 'Cannot open the project.', + description: result.error?.description ?? `The path ${projectPath} does not exist on this computer.`, + variant: 'fail', + }) + }, + [projectPort, handleOpenProjectResponse], + ) + useEffect(() => { - const unsub = accelerator.onOpenRecent((projectData?: unknown) => { + const unsub = accelerator.onOpenRecent((projectPath?: string) => { switch (editingState) { case 'saved': case 'initial-state': - // Process immediately — data comes from the main process IPC event - if (projectData) { - handleOpenProjectResponse(projectData as Parameters[0]) + if (projectPath) { + void openRecentByPath(projectPath) } break case 'unsaved': - // Store pending data and show save modal with callback - pendingRecentProjectRef.current = projectData ?? null + // Hold the path and open it once the save modal has been answered. + pendingRecentProjectRef.current = projectPath ?? null openModal('save-changes-project', { validationContext: 'open-recent-project', onAfterAction: () => { - const data = pendingRecentProjectRef.current + const pending = pendingRecentProjectRef.current pendingRecentProjectRef.current = null - if (data) { - handleOpenProjectResponse(data as Parameters[0]) + if (pending) { + void openRecentByPath(pending) } }, }) @@ -187,7 +208,7 @@ const AcceleratorHandler = () => { } }) return unsub - }, [editingState, accelerator, openModal, handleOpenProjectResponse]) + }, [editingState, accelerator, openModal, openRecentByPath]) /** * Close project diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 6f3981c58..03f1df205 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -26,6 +26,7 @@ import { DebuggerIpInputModal } from '../_organisms/modals/debugger-ip-input-mod import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { ExportPdfModal } from '../_organisms/modals/export-pdf-modal' +import { LibraryUpdatesModal } from '../_organisms/modals/library-updates-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' import { PageSetupModal } from '../_organisms/modals/page-setup-modal' import { ProjectReadmeModal } from '../_organisms/modals/project-readme-modal' @@ -168,6 +169,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['debugger-message']?.open === true && } {modals?.['debugger-ip-input']?.open === true && } {modals?.['missing-libraries']?.open === true && } + {modals?.['library-updates']?.open === true && } {modals?.['public-catalog-browser']?.open === true && } {modals?.['confirm-install-libraries']?.open === true && } {modals?.['project-readme']?.open === true && } diff --git a/src/frontend/hooks/__tests__/use-st-debug-decorations.test.ts b/src/frontend/hooks/__tests__/use-st-debug-decorations.test.ts new file mode 100644 index 000000000..ef9fa93e5 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-st-debug-decorations.test.ts @@ -0,0 +1,207 @@ +/** + * The badge scanner, driven through a stand-in Monaco model. + * + * What matters is which span of which line each badge lands on, so every + * assertion reads the decorations the hook hands to Monaco rather than + * anything rendered. + */ + +import { renderHook } from '@testing-library/react' +import type * as monaco from 'monaco-editor' +import type { RefObject } from 'react' + +import { useStDebugDecorations } from '../use-st-debug-decorations' + +let boolValues = new Map() +let nonBoolValues = new Map() + +jest.mock('../use-debug-value', () => ({ + useDebugBoolValuesMap: () => boolValues, + useDebugNonBoolValuesMap: () => nonBoolValues, +})) + +type Badge = { line: number; startCol: number; endCol: number; content: string } + +const clear = jest.fn() + +/** Enough of Monaco for the hook: a model it can read and a Range it can build. */ +function harness(text: string, uri = 'inmemory://model/1') { + const lines = text.split('\n') + const collections: Badge[][] = [] + + const editor = { + getModel: () => ({ + uri: { toString: () => uri }, + getLineCount: () => lines.length, + getLineContent: (n: number) => lines[n - 1], + }), + createDecorationsCollection: (decorations: monaco.editor.IModelDeltaDecoration[]) => { + collections.push( + decorations.map((d) => ({ + line: d.range.startLineNumber, + startCol: d.range.startColumn, + endCol: d.range.endColumn, + content: String(d.options.after?.content ?? ''), + })), + ) + return { clear } + }, + } + + const monacoInstance = { + Range: class { + constructor( + public startLineNumber: number, + public startColumn: number, + public endLineNumber: number, + public endColumn: number, + ) {} + }, + } + + return { + collections, + editorRef: { current: editor } as unknown as RefObject, + monacoRef: { current: monacoInstance } as unknown as RefObject, + } +} + +const render = (text: string, overrides: Partial[0]> = {}, uri?: string) => { + const h = harness(text, uri) + const result = renderHook(() => + useStDebugDecorations({ + editorRef: h.editorRef, + monacoRef: h.monacoRef, + prefix: 'Prog:', + enabled: true, + modelVersion: text, + ...overrides, + }), + ) + return { ...h, ...result, badges: h.collections[0] ?? [] } +} + +beforeEach(() => { + boolValues = new Map() + nonBoolValues = new Map() + clear.mockClear() +}) + +describe('useStDebugDecorations', () => { + it('badges a live variable where it appears', () => { + nonBoolValues = new Map([['Prog:speed', '42']]) + const { badges } = render('speed := 1;') + + expect(badges).toEqual([{ line: 1, startCol: 1, endCol: 6, content: ' = 42 ' }]) + }) + + it('reads BOOL and non-BOOL values from their separate maps', () => { + boolValues = new Map([['Prog:running', 'TRUE']]) + nonBoolValues = new Map([['Prog:speed', '42']]) + const { badges } = render('running := TRUE;\nspeed := 1;') + + expect(badges.map((b) => b.content)).toEqual([' = TRUE ', ' = 42 ']) + }) + + it('badges only the first occurrence on a line', () => { + nonBoolValues = new Map([['Prog:speed', '42']]) + const { badges } = render('speed := speed + 1;') + + expect(badges).toHaveLength(1) + expect(badges[0].startCol).toBe(1) + }) + + it('matches case-insensitively, as IEC identifiers are', () => { + nonBoolValues = new Map([['Prog:speed', '42']]) + expect(render('SPEED := 1;').badges).toHaveLength(1) + }) + + it('does not badge a name that is only part of a longer identifier', () => { + nonBoolValues = new Map([['Prog:motor', '1']]) + expect(render('motorSpeed := 1;').badges).toEqual([]) + }) + + it('gives an overlapping span to the longer name', () => { + nonBoolValues = new Map([ + ['Prog:motor', '1'], + ['Prog:motor.speed', '42'], + ]) + const { badges } = render('motor.speed := 1;') + + expect(badges).toEqual([{ line: 1, startCol: 1, endCol: 12, content: ' = 42 ' }]) + }) + + it('shows ? when the name is live but carries no value', () => { + nonBoolValues = new Map([['Prog:speed', undefined as unknown as string]]) + expect(render('speed := 1;').badges[0].content).toBe(' = ? ') + }) + + it('ignores names that belong to another prefix', () => { + nonBoolValues = new Map([['Other:speed', '42']]) + expect(render('speed := 1;').badges).toEqual([]) + }) + + it('clears its decorations on unmount', () => { + nonBoolValues = new Map([['Prog:speed', '42']]) + render('speed := 1;').unmount() + + expect(clear).toHaveBeenCalled() + }) +}) + +describe('comments are not scanned', () => { + beforeEach(() => { + nonBoolValues = new Map([['Prog:speed', '42']]) + }) + + it('skips a line comment', () => { + expect(render('// speed is set below').badges).toEqual([]) + }) + + it('skips a (* *) block, including the lines between', () => { + expect(render('(* speed\nspeed\nspeed *)').badges).toEqual([]) + }) + + it('skips a /* */ block', () => { + expect(render('/* speed */').badges).toEqual([]) + }) + + it('resumes badging after a block comment closes', () => { + const { badges } = render('(* speed *)\nspeed := 1;') + + expect(badges).toEqual([{ line: 2, startCol: 1, endCol: 6, content: ' = 42 ' }]) + }) + + it('keeps columns aligned when a comment precedes the name on the same line', () => { + expect(render('(* x *) speed := 1;').badges[0].startCol).toBe(9) + }) +}) + +describe('the gates that switch it off', () => { + beforeEach(() => { + nonBoolValues = new Map([['Prog:speed', '42']]) + }) + + it('does nothing when disabled', () => { + expect(render('speed := 1;', { enabled: false }).collections).toEqual([]) + }) + + it('does nothing without a prefix', () => { + expect(render('speed := 1;', { prefix: undefined }).collections).toEqual([]) + }) + + it('does nothing when no variable is live', () => { + nonBoolValues = new Map() + expect(render('speed := 1;').collections).toEqual([]) + }) + + it('does nothing when the model is not the one the caller expects', () => { + const off = render('speed := 1;', { expectedUri: 'inmemory://model/other' }) + expect(off.collections).toEqual([]) + }) + + it('decorates when the expected model URI matches', () => { + const on = render('speed := 1;', { expectedUri: 'inmemory://model/2' }, 'inmemory://model/2') + expect(on.badges).toHaveLength(1) + }) +}) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index b4357705e..86ea40dd4 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -18,6 +18,7 @@ import { BranchStatusBar } from '../components/_features/[workspace]/branches' import { BranchMergeView } from '../components/_features/[workspace]/branches/branch-merge-view' import { CommitHistoryView } from '../components/_features/[workspace]/commit-history' 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' @@ -60,6 +61,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 { buildAllProjectFileContentsPure } from '../services/save-actions' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' @@ -439,23 +441,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) => { @@ -620,6 +606,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-user-management' && } {editor['type'] === 'plc-persistent-storage' && } {editor['type'] === 'plc-library-manifest' && } + {editor['type'] === 'plc-build-settings' && } {editor['type'] === 'diff-viewer' && } {/* Multi-instance (one tab per deviceId), kept mounted so each device's view state survives tab switches. */} diff --git a/src/frontend/services/__tests__/graphical-scope-array-element.test.ts b/src/frontend/services/__tests__/graphical-scope-array-element.test.ts new file mode 100644 index 000000000..c54885bc8 --- /dev/null +++ b/src/frontend/services/__tests__/graphical-scope-array-element.test.ts @@ -0,0 +1,185 @@ +/** + * An array element named by a contact, a coil or a variable box. + * + * IEC 61131-3 Ed 3 §8.1.2 shows both spellings on an LD contact — + * + * Xs[3] "as an array element with constant subscript" + * Xs[i] "as an array element with variable subscript" + * + * — under "All supported data types SHALL be accessible as operands or + * parameters in the graphical languages". §6.4.4.5.1 narrows what a subscript + * may be in a graphical language: "single-element variables or integer + * literals", nothing computed. + * + * The constant form resolves through the language server, which publishes one + * symbol per in-bounds element. That is what makes `Xs[99]` flag itself, and + * it must keep doing so. + * + * The variable form has no such symbol and never could, so it is resolved + * here. §6.4.4.5.1's own note says the bounds cannot be checked anyway: + * "This error can be detected only at runtime for a computed index." + */ + +import { afterEach, describe, expect, it } from '@jest/globals' + +jest.mock('../st-lsp', () => require('../st-lsp/scoped-query')) + +import { isExpressionValidForType, resolveScopeExpressionType } from '../graphical-scope' +import { registerScopedQueryApi, type ScopedCompletionItem } from '../st-lsp/scoped-query' + +afterEach(() => registerScopedQueryApi(null)) + +/** Kind 6 is LSP `Variable` — what strucpp reports for an in-scope symbol. */ +const member = (label: string, type: string): ScopedCompletionItem => ({ + label, + insertText: label, + type, + kind: 6, +}) + +const withScope = (byAnchor: Record) => + registerScopedQueryApi({ + completeInScope: (_pouName: string, prefix: string) => Promise.resolve(byAnchor[prefix] ?? []), + }) + +/** A POU holding a 1-D BOOL array, a 2-D INT array, and some subscripts. */ +const LOCALS: ScopedCompletionItem[] = [ + member('bits', 'ARRAY [0..3] OF BOOL'), + member('bits[0]', 'BOOL'), + member('bits[1]', 'BOOL'), + member('bits[2]', 'BOOL'), + member('bits[3]', 'BOOL'), + member('grid', 'ARRAY [0..1, 0..1] OF INT'), + member('grid[0,0]', 'INT'), + member('grid[0,1]', 'INT'), + member('grid[1,0]', 'INT'), + member('grid[1,1]', 'INT'), + member('i', 'INT'), + member('j', 'DINT'), + member('ratio', 'REAL'), + member('flag', 'BOOL'), +] + +describe('an array element with a constant subscript', () => { + it('resolves to the element type', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[2]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('stays flagged when the index is out of bounds', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[99]')).resolves.toEqual({ status: 'unknown' }) + }) +}) + +describe('an array element with a variable subscript', () => { + it('resolves to the element type — §8.1.2, "variable subscript"', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[i]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('is therefore usable on a BOOL contact', async () => { + withScope({ '': LOCALS }) + await expect(isExpressionValidForType('P', 'bits[i]', 'BOOL')).resolves.toBe(true) + }) + + it('takes any ANY_INT subscript, not only INT', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[j]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('works on each dimension of a multi-dimensional array', async () => { + withScope({ '': LOCALS }) + for (const expr of ['grid[i,0]', 'grid[0,i]', 'grid[i,j]']) { + await expect(resolveScopeExpressionType('P', expr)).resolves.toEqual({ + status: 'resolved', + type: 'INT', + }) + } + }) + + it('refuses a subscript that is not an integer — §6.4.4.5.1', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[ratio]')).resolves.toEqual({ status: 'unknown' }) + await expect(resolveScopeExpressionType('P', 'bits[flag]')).resolves.toEqual({ status: 'unknown' }) + }) + + it('refuses a subscript that is not in scope at all', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[nosuch]')).resolves.toEqual({ status: 'unknown' }) + }) + + it('refuses the wrong number of subscripts for the array', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'bits[i,j]')).resolves.toEqual({ status: 'unknown' }) + await expect(resolveScopeExpressionType('P', 'grid[i]')).resolves.toEqual({ status: 'unknown' }) + }) + + it('refuses a subscript on something that is not an array', async () => { + withScope({ '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'ratio[i]')).resolves.toEqual({ status: 'unknown' }) + }) +}) + +describe('an array element of a global variable list member', () => { + // A list compiles to a STRUCT, so this is the `LIST.member[i]` path — the one + // the RS-485 rig uses for everything arriving off the bus. + const NET: ScopedCompletionItem[] = [ + member('P20Di', 'ARRAY [0..7] OF BOOL'), + member('P20Di[0]', 'BOOL'), + member('P20Di[1]', 'BOOL'), + member('idx', 'INT'), + ] + + it('resolves a constant subscript', async () => { + withScope({ 'NET.': NET, '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'NET.P20Di[1]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('takes a subscript from the POU, not from the list', async () => { + // `i` in `NET.P20Di[i]` is a variable of the POU. Looking it up under the + // list's anchor would ask for `NET.i`, which does not exist. + withScope({ 'NET.': NET, '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'NET.P20Di[i]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('also takes a subscript written out as a list member', async () => { + withScope({ 'NET.': NET, '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'NET.P20Di[NET.idx]')).resolves.toEqual({ + status: 'resolved', + type: 'BOOL', + }) + }) + + it('does not mistake a dot inside a subscript for a member chain', async () => { + // `splitExpression` used to cut at the last dot wherever it was, making + // the anchor `NET.P20Di[NET.` — which could never resolve. + withScope({ 'NET.': NET, '': LOCALS }) + await expect(resolveScopeExpressionType('P', 'NET.P20Di[NET.nosuch]')).resolves.toEqual({ + status: 'unknown', + }) + }) +}) + +describe('while the language server is still warming up', () => { + it('does not paint a false error', async () => { + withScope({}) + await expect(resolveScopeExpressionType('P', 'bits[i]')).resolves.toEqual({ status: 'unavailable' }) + await expect(isExpressionValidForType('P', 'bits[i]', 'BOOL')).resolves.toBe(true) + }) +}) diff --git a/src/frontend/services/__tests__/graphical-scope-block-instance.test.ts b/src/frontend/services/__tests__/graphical-scope-block-instance.test.ts new file mode 100644 index 000000000..45b282a25 --- /dev/null +++ b/src/frontend/services/__tests__/graphical-scope-block-instance.test.ts @@ -0,0 +1,81 @@ +/** + * A block instance that lives in a global variable list. + * + * The ladder and FBD block elements resolve their instance name against the + * POU's own `interface.variables`. A global variable list member — `NET.node`, + * the pattern the ModBee docs use so one node can be shared by a fast task and + * the application — is not in that list under any spelling, so the block + * painted itself with the red "wrong variable" ring while the project compiled + * and ran perfectly. + * + * The lists are known to the LSP (`st-lsp/project-sync` reconciles them), which + * is why contacts and coils on `IO.DO01` were always fine: they validate + * through this module. This is the same question asked for a block instance. + */ + +import { afterEach, describe, expect, it } from '@jest/globals' + +// `graphical-scope` reaches the LSP through the `st-lsp` barrel, which pulls +// `vscode-languageserver-protocol` — ESM that jest does not transform. Every +// symbol it actually uses lives in `scoped-query`, so stand that in for the +// barrel: the real resolution logic runs, only the unreachable import goes. +jest.mock('../st-lsp', () => require('../st-lsp/scoped-query')) + +import { isBlockInstanceInScope } from '../graphical-scope' +import { registerScopedQueryApi, type ScopedCompletionItem } from '../st-lsp/scoped-query' + +afterEach(() => registerScopedQueryApi(null)) + +/** Stand in for the LSP, answering one anchor with the members it knows. */ +const withScope = (byAnchor: Record) => + registerScopedQueryApi({ + completeInScope: (_pouName: string, prefix: string) => Promise.resolve(byAnchor[prefix] ?? []), + }) + +/** Kind 6 is LSP `Variable` — what strucpp reports for an in-scope symbol. */ +const member = (label: string, type: string): ScopedCompletionItem => ({ + label, + insertText: label, + type, + kind: 6, +}) + +describe('a function-block instance held in a global variable list', () => { + it('is in scope when the list member has the block type', async () => { + withScope({ 'NET.': [member('node', 'NODE'), member('Level', 'REAL')] }) + await expect(isBlockInstanceInScope('RunNode', 'NET.node', 'NODE')).resolves.toBe(true) + }) + + it('matches the type without regard to case, as ST does', async () => { + withScope({ 'NET.': [member('node', 'node')] }) + await expect(isBlockInstanceInScope('RunNode', 'NET.node', 'NODE')).resolves.toBe(true) + }) + + it('is not in scope when the member is of some other type', async () => { + withScope({ 'NET.': [member('node', 'REAL')] }) + await expect(isBlockInstanceInScope('RunNode', 'NET.node', 'NODE')).resolves.toBe(false) + }) + + it('is not in scope when the list has no such member', async () => { + withScope({ 'NET.': [member('Level', 'REAL')] }) + await expect(isBlockInstanceInScope('RunNode', 'NET.missing', 'NODE')).resolves.toBe(false) + }) + + it('answers for a plain local instance too', async () => { + withScope({ '': [member('digIn', 'DIGITAL_IN')] }) + await expect(isBlockInstanceInScope('ReadInputs', 'digIn', 'DIGITAL_IN')).resolves.toBe(true) + }) +}) + +describe('when the LSP cannot answer', () => { + it('says undefined rather than false, so the caller leaves the block alone', async () => { + // No API registered at all — boot, a worker crash, or a test env. + await expect(isBlockInstanceInScope('RunNode', 'NET.node', 'NODE')).resolves.toBeUndefined() + }) + + it('says undefined while the worker has no context yet', async () => { + // Registered but answering empty, which the resolver treats as warming up. + withScope({}) + await expect(isBlockInstanceInScope('RunNode', 'NET.node', 'NODE')).resolves.toBeUndefined() + }) +}) diff --git a/src/frontend/services/graphical-scope.ts b/src/frontend/services/graphical-scope.ts index 156dbf4e6..942a99ba5 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -29,6 +29,7 @@ import { } from '../utils/PLC/validate-variable-type' import { collectDeclaredRoots, rootIdentifierOf } from './project-scope-roots' // The leaf, not the `st-lsp` barrel: the barrel pulls ESM-only LSP packages that Jest cannot transform. +import type { ScopedCompletionItem } from './st-lsp/scoped-query' import { getScopedQueryApi, isValueCompletionKind, splitExpression } from './st-lsp/scoped-query' /** Max instance/struct variables to drill into when a type-filtered search has no direct hits. */ @@ -150,9 +151,85 @@ export async function resolveScopeExpressionType(pouName: string, expression: st const match = items.find( (item) => isValueCompletionKind(item.kind) && item.label.toLowerCase() === segment.toLowerCase(), ) - if (!match || !match.type) return { status: 'unknown' } + if (match && match.type) return { status: 'resolved', type: match.type } - return { status: 'resolved', type: match.type } + // No symbol by that name. One case is still legal and has to be resolved + // here rather than by the language server: a subscript that is a VARIABLE. + return resolveVariableSubscript(pouName, segment, items) +} + +/** `base` and the subscript expressions of `base[a, b]`, or undefined. */ +function splitSubscripts(segment: string): { base: string; subscripts: string[] } | undefined { + const open = segment.indexOf('[') + if (open <= 0 || !segment.trimEnd().endsWith(']')) return undefined + const base = segment.slice(0, open) + const inner = segment.slice(open + 1, segment.lastIndexOf(']')) + if (inner.includes('[')) return undefined // nested subscripts are out of scope + const subscripts = inner.split(',').map((x) => x.trim()) + if (subscripts.some((x) => x.length === 0)) return undefined + return { base, subscripts } +} + +const INTEGER_LITERAL = /^[+-]?\d+$/ + +/** + * `arr[i]` — an array element whose subscript is a variable. + * + * IEC 61131-3 Ed 3 §6.4.4.5.1 restricts a subscript in the graphical languages + * to "single-element variables or integer literals", and §8.1.2 shows exactly + * this on a contact: `Xs[i]`, *"as an array element with variable subscript"*. + * So it is legal and the box must not be flagged. + * + * The language server cannot answer it. It publishes one symbol per in-bounds + * element — `arr[0]`, `arr[1]`, … — which is what makes a LITERAL subscript + * bounds-checked for free, and deliberately so: `arr[99]` is not a symbol and + * stays flagged. A variable subscript has no such symbol and never could, and + * the standard agrees it cannot be checked here — §6.4.4.5.1 note: *"This + * error can be detected only at runtime for a computed index."* + * + * So resolve it from the element symbols instead, and check what can be + * checked: the base is an array in scope, the subscript count matches its + * dimensions, and every variable subscript is an integer. A REAL subscript + * stays unknown — the standard does not allow one. + */ +async function resolveVariableSubscript( + pouName: string, + segment: string, + items: ScopedCompletionItem[], +): Promise { + const parts = splitSubscripts(segment) + if (!parts) return { status: 'unknown' } + + // All-literal subscripts already had their chance above. Reaching here means + // the element is out of bounds, which is a real fault worth showing. + if (parts.subscripts.every((x) => INTEGER_LITERAL.test(x))) return { status: 'unknown' } + + // Any element symbol of this array carries the element type, and its own + // subscript count is the array's dimensionality. Taking it from the symbol + // rather than parsing the rendered `ARRAY [0..3] OF BOOL` keeps the language + // server the authority on both. + const prefix = `${parts.base.toLowerCase()}[` + const element = items.find((item) => isValueCompletionKind(item.kind) && item.label.toLowerCase().startsWith(prefix)) + if (!element || !element.type) return { status: 'unknown' } + + const elementParts = splitSubscripts(element.label) + if (!elementParts || elementParts.subscripts.length !== parts.subscripts.length) { + return { status: 'unknown' } + } + + for (const subscript of parts.subscripts) { + if (INTEGER_LITERAL.test(subscript)) continue + // Resolved from the POU's own scope, NOT from the array's anchor. The `i` + // in `NET.bits[i]` is a variable of the POU, not a member of `NET` — and a + // subscript that really is a list member is written out in full as + // `NET.bits[NET.idx]`, which resolves here just the same. + const subscriptType = await resolveScopeExpressionType(pouName, subscript) + if (subscriptType.status === 'unavailable') return { status: 'unavailable' } + if (subscriptType.status !== 'resolved') return { status: 'unknown' } + if (!validateVariableType(subscriptType.type, 'ANY_INT').isValid) return { status: 'unknown' } + } + + return { status: 'resolved', type: element.type } } /** @@ -171,6 +248,30 @@ export async function isExpressionValidForType( return validateVariableType(result.type, expectedType).isValid } +/** + * Is `name` an instance of function-block type `blockType` in this POU's scope? + * + * A block element resolves its instance name against the POU's own + * `interface.variables`, which structurally cannot see a **global variable + * list** member: `NET.node` is not in that list under any spelling, so the + * block painted itself as an error while compiling perfectly. This asks the + * LSP instead, the way the contacts, coils and variable boxes already do. + * + * Returns `undefined` when the LSP cannot answer, so the caller leaves the + * block alone rather than flashing red while the worker warms up — the same + * contract `isExpressionValidForType` keeps for its own `unavailable`. + */ +export async function isBlockInstanceInScope( + pouName: string, + name: string, + blockType: string, +): Promise { + const result = await resolveScopeExpressionType(pouName, name) + if (result.status === 'unavailable') return undefined + if (result.status === 'unknown') return false + return result.type.toLowerCase() === blockType.toLowerCase() +} + /** * Concrete `{definition, value}` to type a brand-new variable created from a * box's expected type. `boundSiblings` are the pins of the same block instance 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/services/save-actions.ts b/src/frontend/services/save-actions.ts index ea5056f01..01dc0faca 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -20,6 +20,7 @@ import { serializeDataTypeToText } from '../utils/PLC/data-type-serializer' import { getExtensionFromLanguage, getFolderFromPouType } from '../utils/PLC/pou-file-extensions' import { parseGraphicalPouFromString, parseTextualPouFromString } from '../utils/PLC/pou-text-parser' import { serializePouToText } from '../utils/PLC/pou-text-serializer' +import { withProjectLibraries } from '../utils/PLC/project-libraries-json' import { carryEditorMetadata } from '../utils/PLC/variable-metadata' import { collectDebugVariables, sanitizePou } from '../utils/save-project' import { toast } from '../utils/toast' @@ -927,34 +928,18 @@ async function saveLibraryManagerOnly( state: ReturnType, ): Promise<{ success: boolean; error?: string }> { const fullPath = joinPath(projectPath, 'project.json') - const refs = state.project.data.libraries ?? [] - const sortedRefs = [...refs] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((r) => ({ name: r.name, version: r.version })) const read = await projectPort.readFileContent(fullPath) if (!read.success || typeof read.content !== 'string') { return projectPort.saveFile(fullPath, buildProjectJsonContent(state)) } - let onDisk: Record - try { - const parsed = JSON.parse(read.content) as unknown - if (typeof parsed !== 'object' || parsed === null) { - return { success: false, error: 'project.json on disk is not an object' } - } - onDisk = parsed as Record - } catch { - return { success: false, error: 'project.json on disk is malformed' } - } - - const data = - onDisk.data && typeof onDisk.data === 'object' - ? (onDisk.data as Record) - : ((onDisk.data = {}), onDisk.data as Record) - data.libraries = sortedRefs + // Shared with the CLI's `library pin`, so the two cannot write the field + // differently. + const rewritten = withProjectLibraries(read.content, state.project.data.libraries ?? []) + if (!rewritten.ok) return { success: false, error: rewritten.error } - return projectPort.saveFile(fullPath, JSON.stringify(onDisk, null, 2)) + return projectPort.saveFile(fullPath, rewritten.json) } function vendorScreenOwnedKeysFor(state: ReturnType, screenName: string): string[] { diff --git a/src/frontend/services/st-lsp/scoped-query.ts b/src/frontend/services/st-lsp/scoped-query.ts index cb499d937..4dd359d1e 100644 --- a/src/frontend/services/st-lsp/scoped-query.ts +++ b/src/frontend/services/st-lsp/scoped-query.ts @@ -93,7 +93,20 @@ export function isValueCompletionKind(kind: number | undefined): boolean { * resolves differently depending on which one asked. */ export function splitExpression(value: string): { anchor: string; segment: string } { - const lastDot = value.lastIndexOf('.') + // Split at the last dot that is NOT inside a subscript. A dot between + // brackets belongs to the index expression, not to the member chain: + // `arr[s.k]` is one element of `arr`, not a member `k` of something called + // `arr[s`. Splitting naively there produced an anchor that could never + // resolve and a segment that was not an identifier. + let lastDot = -1 + let depth = 0 + for (let i = 0; i < value.length; i++) { + const ch = value[i] + if (ch === '[') depth++ + else if (ch === ']') { + if (depth > 0) depth-- + } else if (ch === '.' && depth === 0) lastDot = i + } if (lastDot < 0) return { anchor: '', segment: value } return { anchor: value.slice(0, lastDot + 1), segment: value.slice(lastDot + 1) } } diff --git a/src/frontend/store/__tests__/library-block-resolution.test.ts b/src/frontend/store/__tests__/library-block-resolution.test.ts new file mode 100644 index 000000000..7aedac6b0 --- /dev/null +++ b/src/frontend/store/__tests__/library-block-resolution.test.ts @@ -0,0 +1,353 @@ +/** + * A placed library block must still resolve to its library after a project + * opens. + * + * The graphical editors ring a block red when its instance variable does not + * classify as `derived` against the block's own name — which is what happens + * when the library pool is missing or blank at the moment project load + * reclassifies the POU's variables. These drive the real open path and assert + * that predicate directly. + */ + +import { createStore } from 'zustand/vanilla' + +import type { SystemLibrary } from '../../../middleware/shared/ports/library-types' +import { createAISlice } from '../slices/ai' +import { createConsoleSlice } from '../slices/console/slice' +import { createDeviceSlice } from '../slices/device/slice' +import { createEditorSlice } from '../slices/editor/slice' +import { createFBDFlowSlice } from '../slices/fbd/slice' +import { createFileSlice } from '../slices/file/slice' +import { createHistorySlice } from '../slices/history/slice' +import { createLadderFlowSlice } from '../slices/ladder/slice' +import { createLibrarySlice } from '../slices/library/slice' +import { createModalSlice } from '../slices/modal/slice' +import { createProjectSlice } from '../slices/project/slice' +import { createSearchSlice } from '../slices/search/slice' +import { createSharedSlice } from '../slices/shared/slice' +import type { SharedRootState } from '../slices/shared/types' +import { createTabsSlice } from '../slices/tabs/slice' +import { createVersionControlSlice } from '../slices/version-control/slice' +import { createWorkspaceSlice } from '../slices/workspace/slice' + +function makeStore() { + return createStore()((...args) => ({ + ...createProjectSlice(...args), + ...createFileSlice(...args), + ...createEditorSlice(...args), + ...createTabsSlice(...args), + ...createLibrarySlice(...args), + ...createWorkspaceSlice(...args), + ...createModalSlice(...args), + ...createSearchSlice(...args), + ...createConsoleSlice(...args), + ...createDeviceSlice(...args), + ...createFBDFlowSlice(...args), + ...createLadderFlowSlice(...args), + ...createHistorySlice(...args), + ...createVersionControlSlice(...args), + ...createAISlice(...args), + ...createSharedSlice(...args), + })) +} + +const COUNTER_PINS = [ + { name: 'CU', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'PV', class: 'input', type: { definition: 'base-type', value: 'INT' } }, + { name: 'Q', class: 'output', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'CV', class: 'output', type: { definition: 'base-type', value: 'INT' } }, +] + +function library(version = '0.1.0', pins = COUNTER_PINS, documentation = 'v1 doc'): SystemLibrary { + return { + name: 'libtest-basic', + author: '', + version, + stPath: '', + cPath: '', + pous: [{ name: 'COUNTER_FB', type: 'function-block', language: 'st', body: '', documentation, variables: pins }], + } as unknown as SystemLibrary +} + +/** A ladder POU with one placed COUNTER_FB, shaped as the editor saves it. */ +function ladderProject(pinnedVersion: string) { + const variant = { + name: 'COUNTER_FB', + type: 'function-block', + documentation: 'stale doc', + extensible: false, + variables: [ + { name: 'EN', class: 'input', type: { definition: 'generic-type', value: 'BOOL' } }, + { name: 'ENO', class: 'output', type: { definition: 'generic-type', value: 'BOOL' } }, + ...COUNTER_PINS, + ], + } + return { + meta: { name: 'proj-ladder', type: 'plc-project' as const, path: '/bed/proj-ladder' }, + projectData: { + dataTypes: [], + globalVariableLists: [], + libraries: [{ name: 'libtest-basic', version: pinnedVersion }], + pous: [ + { + name: 'main', + pouType: 'program' as const, + documentation: '', + interface: { + variables: [ + { + name: 'COUNTER_FB0', + class: 'local' as const, + type: { definition: 'derived' as const, value: 'COUNTER_FB' }, + location: '', + documentation: '', + }, + ], + }, + body: { + language: 'ld' as const, + value: { + name: 'main', + rungs: [ + { + id: 'rung-1', + comment: '', + defaultBounds: [300, 100], + reactFlowViewport: [900, 400], + selectedNodes: [], + edges: [], + nodes: [ + { + // A variable wired to PV keeps its own copy of that pin's + // signature; the canvas renders it as `(*TYPE*)` and + // validates against it. + id: 'VARIABLE_1', + type: 'variable', + data: { + variant: 'input', + variable: { name: '' }, + block: { + id: 'BLOCK_1', + handleId: 'PV', + variableType: { name: 'PV', class: 'input', type: { definition: 'base-type', value: 'INT' } }, + }, + }, + }, + { + id: 'BLOCK_1', + type: 'block', + position: { x: 300, y: 30 }, + data: { + variant, + variable: { id: 'v1', name: 'COUNTER_FB0' }, + handles: [], + inputHandles: [], + outputHandles: [], + connectedVariables: [], + }, + }, + ], + }, + ], + } as unknown, + }, + }, + ], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + } +} + +/** The predicate `ladder/block.tsx` uses to ring a block red. */ +function ringsRed(store: ReturnType): boolean { + const pou = store.getState().project.data.pous.find((p) => p.name === 'main') + const variable = pou?.interface?.variables.find((v) => v.name === 'COUNTER_FB0') + if (!variable) return true + return !(variable.type.definition === 'derived' && variable.type.value.toLowerCase() === 'counter_fb') +} + +describe('a placed library block after project open', () => { + let store: ReturnType + + beforeEach(() => { + store = makeStore() + }) + + it('resolves against the library, so the block is not marked wrong', () => { + store.getState().libraryActions.setSystemLibraries([library()]) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + + expect(store.getState().libraries.system.map((l) => l.name)).toEqual(['libtest-basic']) + expect(ringsRed(store)).toBe(false) + }) + + it('still resolves when the project pins a version that is not installed', () => { + store.getState().libraryActions.setSystemLibraries([library('0.1.0')]) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('9.9.9') as never) + + expect(store.getState().libraries.system).toHaveLength(1) + expect(ringsRed(store)).toBe(false) + }) + + it('re-stamps the placed block from the library', () => { + store.getState().libraryActions.setSystemLibraries([library()]) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + + const flow = store.getState().ladderFlows.find((f) => f.name === 'main') + const node = flow?.rungs[0]?.nodes.find((n) => n.type === 'block') + const variant = (node?.data as { variant: { documentation: string } }).variant + expect(variant.documentation).toBe('v1 doc') + }) + + it('offers the newer version and switches to it without marking the block wrong', () => { + const v2Pins = [ + { name: 'CU', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'PV', class: 'input', type: { definition: 'base-type', value: 'REAL' } }, + { name: 'RESET', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'Q', class: 'output', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'CV', class: 'output', type: { definition: 'base-type', value: 'INT' } }, + ] + store.getState().libraryActions.setSystemLibraries([library('0.2.0', v2Pins, 'v2 doc'), library('0.1.0')]) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + + // Pinned to 0.1.0, with 0.2.0 available. + expect(store.getState().libraries.system[0].version).toBe('0.1.0') + expect(store.getState().outdatedLibraries).toEqual([ + { name: 'libtest-basic', pinned: '0.1.0', available: ['0.2.0', '0.1.0'] }, + ]) + expect(ringsRed(store)).toBe(false) + + store.getState().libraryActions.setLibraryVersion('libtest-basic', '0.2.0') + + expect(store.getState().libraries.system[0].version).toBe('0.2.0') + expect(store.getState().outdatedLibraries).toEqual([]) + expect(ringsRed(store)).toBe(false) + }) + + it('writes the refresh into the project body, not just the canvas flow', () => { + // Everything that persists or compiles reads `pou.body.value`; only the + // canvas reads the flow. Refreshing one and not the other throws the work + // away on save and re-reports it on the next open. + const v2Pins = COUNTER_PINS.map((pin) => + pin.name === 'PV' ? { ...pin, type: { definition: 'base-type', value: 'REAL' } } : pin, + ) + store.getState().libraryActions.setSystemLibraries([library('0.2.0', v2Pins, 'v2 doc')]) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.2.0') as never) + + const pinType = (variables: { name: string; type: { value: string } }[]) => + variables.find((v) => v.name === 'PV')?.type.value + + const flow = store.getState().ladderFlows.find((f) => f.name === 'main') + const flowNode = flow?.rungs[0]?.nodes.find((n) => n.type === 'block') + expect(pinType((flowNode?.data as { variant: { variables: never[] } }).variant.variables)).toBe('REAL') + + const body = store.getState().project.data.pous.find((p) => p.name === 'main')?.body.value as { + rungs: { nodes: { type: string; data: { variant: { variables: never[]; documentation: string } } }[] }[] + } + const bodyNode = body.rungs[0].nodes.find((n) => n.type === 'block')! + expect(pinType(bodyNode.data.variant.variables)).toBe('REAL') + expect(bodyNode.data.variant.documentation).toBe('v2 doc') + + // The variable wired to PV must move with it, or the canvas keeps showing + // `(*INT*)` and rejects a REAL variable dropped on the pin. + const wired = body.rungs[0].nodes.find( + (n) => (n.data as never as { block?: { handleId?: string } })?.block?.handleId === 'PV', + )! + expect( + (wired.data as never as { block: { variableType: { type: { value: string } } } }).block.variableType.type.value, + ).toBe('REAL') + + // And the project must be marked unsaved, or the refresh is never written + // back and is redone on the next open. + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('leaves a text POU alone: ST holds no signature to go stale', () => { + // An ST body is a string. The block is reached through a declaration, and + // the compiler resolves it against the pinned library, so there is nothing + // cached that could disagree with the library -- and nothing to re-stamp. + store.getState().libraryActions.setSystemLibraries([library()]) + + const stProject = { + meta: { name: 'proj-st', type: 'plc-project' as const, path: '/bed/proj-st' }, + projectData: { + dataTypes: [], + globalVariableLists: [], + libraries: [{ name: 'libtest-basic', version: '0.1.0' }], + pous: [ + { + name: 'main', + pouType: 'program' as const, + documentation: '', + interface: { + variables: [ + { + name: 'COUNTER_FB0', + class: 'local' as const, + type: { definition: 'derived' as const, value: 'COUNTER_FB' }, + location: '', + documentation: '', + }, + ], + }, + body: { language: 'st' as const, value: 'COUNTER_FB0(CU := TRUE);' as unknown }, + }, + ], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + } + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(stProject as never) + + // Nothing re-stamped, nothing dirtied, and the instance still resolves. + expect(store.getState().workspace.editingState).toBe('saved') + expect(ringsRed(store)).toBe(false) + expect(store.getState().project.data.pous[0].body.value).toBe('COUNTER_FB0(CU := TRUE);') + }) + + it('marks the block wrong when the library is genuinely absent', () => { + // The control: with no pool, the variable cannot classify as `derived`. + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + + expect(ringsRed(store)).toBe(true) + }) + + it('settles once a pool that was stale at open is re-read', () => { + // What `openplc-cli library install` leaves behind: the editor hydrated + // its pool at start-up, the library was installed afterwards by another + // process, and the project that needs it opens against the stale pool -- + // reported missing, block ringed red, and only a restart fixed it. + // + // App.tsx re-reads the pool whenever `project.meta.path` changes, which is + // the same signal that navigates start -> workspace. This is what that + // re-read has to achieve, and why nothing else has to be re-run: the refs + // come from the project, so the derived lists are rebuilt from the project + // that is now open. + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + expect(store.getState().missingLibraries.map((l) => l.name)).toEqual(['libtest-basic']) + expect(ringsRed(store)).toBe(true) + + store.getState().libraryActions.setSystemLibraries([library()]) + + expect(store.getState().missingLibraries).toEqual([]) + expect(store.getState().enabledLibraries).toEqual(['libtest-basic']) + }) + + it('re-reading a pool that already had the library changes nothing', () => { + // The re-read fires on every open, so the ordinary case must be inert. + store.getState().libraryActions.setSystemLibraries([library()]) + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(ladderProject('0.1.0') as never) + const before = store.getState().libraries.system + + store.getState().libraryActions.setSystemLibraries([library()]) + + expect(store.getState().missingLibraries).toEqual([]) + expect(store.getState().libraries.system).toEqual(before) + expect(ringsRed(store)).toBe(false) + }) +}) diff --git a/src/frontend/store/__tests__/library-slice.test.ts b/src/frontend/store/__tests__/library-slice.test.ts index 82996225d..eda852879 100644 --- a/src/frontend/store/__tests__/library-slice.test.ts +++ b/src/frontend/store/__tests__/library-slice.test.ts @@ -354,6 +354,108 @@ describe('createLibrarySlice', () => { expect(store.getState().enabledLibraries).toEqual([]) }) }) + describe('multiple installed versions', () => { + const twoVersions = () => [ + makeSystemLibrary({ name: 'node-uio', version: '0.0.2' }), + makeSystemLibrary({ name: 'node-uio', version: '0.0.1' }), + makeSystemLibrary({ name: 'other', version: '1.0.0' }), + ] + + it('keeps every version installed but exposes one per name', () => { + store.getState().libraryActions.setSystemLibraries(twoVersions()) + + expect(store.getState().installedLibraries).toHaveLength(3) + expect(store.getState().libraries.system.map((l) => `${l.name}@${l.version}`)).toEqual([ + 'node-uio@0.0.2', + 'other@1.0.0', + ]) + }) + + it('exposes the version the project pins, not the newest', () => { + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '0.0.1' }]) + + expect(store.getState().libraries.system.find((l) => l.name === 'node-uio')?.version).toBe('0.0.1') + }) + + it('falls back to the newest when the pinned version is not installed', () => { + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '9.9.9' }]) + + expect(store.getState().libraries.system.find((l) => l.name === 'node-uio')?.version).toBe('0.0.2') + // Resolved, so not missing. + expect(store.getState().missingLibraries).toEqual([]) + }) + + it('reports a pinned library that has a newer version installed', () => { + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '0.0.1' }]) + + expect(store.getState().outdatedLibraries).toEqual([ + { name: 'node-uio', pinned: '0.0.1', available: ['0.0.2', '0.0.1'] }, + ]) + }) + + it('reports nothing outdated when the project pins the newest', () => { + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '0.0.2' }]) + + expect(store.getState().outdatedLibraries).toEqual([]) + }) + + /** `setLibraryVersion` reads the project's durable list, which the slim + * harness does not have; seed the shape it reads. */ + const withProjectSlice = () => + store.setState({ project: { data: { libraries: [] } } } as unknown as Parameters[0]) + + it('setLibraryVersion repins and re-derives the effective pool', () => { + withProjectSlice() + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '0.0.2' }]) + + store.getState().libraryActions.setLibraryVersion('node-uio', '0.0.1') + + expect(store.getState().libraries.system.find((l) => l.name === 'node-uio')?.version).toBe('0.0.1') + expect(store.getState().outdatedLibraries).toEqual([ + { name: 'node-uio', pinned: '0.0.1', available: ['0.0.2', '0.0.1'] }, + ]) + }) + + it('setLibraryVersion is a no-op for a library the project does not use', () => { + withProjectSlice() + store.getState().libraryActions.setSystemLibraries(twoVersions()) + store.getState().libraryActions.setProjectLibraries([]) + + store.getState().libraryActions.setLibraryVersion('node-uio', '0.0.1') + + expect(store.getState().libraries.system.find((l) => l.name === 'node-uio')?.version).toBe('0.0.2') + }) + + it('never blanks a pool it was not given the installed list for', () => { + // The pool can be on show before `installedLibraries` is populated. + // Narrowing an empty list there would drop every library block's type, + // which leaves each FB instance variable unresolvable and rings the + // block red. + withProjectSlice() + store.setState({ libraries: { system: [makeSystemLibrary({ name: 'node-uio' })], user: [] } } as never) + + store.getState().libraryActions.setProjectLibraries([{ name: 'node-uio', version: '1.0' }]) + + expect(store.getState().libraries.system.map((l) => l.name)).toEqual(['node-uio']) + expect(store.getState().missingLibraries).toEqual([]) + }) + + it('orders versions by semver, not lexically', () => { + store + .getState() + .libraryActions.setSystemLibraries([ + makeSystemLibrary({ name: 'lib', version: '0.9.0' }), + makeSystemLibrary({ name: 'lib', version: '0.10.0' }), + ]) + + expect(store.getState().libraries.system[0].version).toBe('0.10.0') + }) + }) }) /** diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 9adbbbbd3..4bf75c98e 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -228,6 +228,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/library/slice.ts b/src/frontend/store/slices/library/slice.ts index 9b6b9c254..89cf3b33a 100644 --- a/src/frontend/store/slices/library/slice.ts +++ b/src/frontend/store/slices/library/slice.ts @@ -2,8 +2,9 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' import { librariesOwningTypes } from '../../../utils/library-usage' +import { compareSemver } from '../../../utils/semver' import type { SharedRootState } from '../shared/types' -import type { LibraryProjectRef, LibrarySlice } from './types' +import type { LibraryProjectRef, LibrarySlice, OutdatedLibrary, SystemLibrary } from './types' /** * The library slice is created with a narrow `LibrarySlice` state @@ -39,9 +40,11 @@ const createLibrarySlice: StateCreator = (se system: [], user: [], }, + installedLibraries: [], enabledLibraries: [], bundledLibraryNames: [], missingLibraries: [], + outdatedLibraries: [], libraryActions: { setSystemLibraries: (libraries) => { // Strip malformed POU entries at the source — keep only @@ -57,13 +60,15 @@ const createLibrarySlice: StateCreator = (se })) setState( produce((state: LibrarySlice) => { - state.libraries.system = sanitized - // System pool changed — refresh the derived diff against - // the project's current durable list. Empty when the - // project slice isn't wired (slim test harness). + // `sanitized` carries every installed version; `system` carries + // the one this project uses. Kept apart so switching a project's + // pinned version needs no round trip to the main process. + state.installedLibraries = sanitized const refs = readProjectRefs() - state.enabledLibraries = computeEnabled(sanitized, refs) - state.missingLibraries = computeMissing(sanitized, refs) + state.libraries.system = effectivePool(sanitized, refs) + state.enabledLibraries = computeEnabled(state.libraries.system, refs) + state.missingLibraries = computeMissing(state.libraries.system, refs) + state.outdatedLibraries = computeOutdated(sanitized, refs) }), ) }, @@ -155,8 +160,27 @@ const createLibrarySlice: StateCreator = (se // Mirror into the project slice (when present) so the // durable list and the derived view stay in sync. mutateProjectRefs(state, () => refs.map((r) => ({ name: r.name, version: r.version }))) + // Re-derive: the project may pin versions other than the newest. + const installed = poolToNarrow(state) + state.libraries.system = effectivePool(installed, refs) state.enabledLibraries = computeEnabled(state.libraries.system, refs) state.missingLibraries = computeMissing(state.libraries.system, refs) + state.outdatedLibraries = computeOutdated(installed, refs) + }), + ) + }, + setLibraryVersion: (name, version) => { + setState( + produce((state: LibrarySlice) => { + let refs = readProjectRefs() + if (!refs.some((ref) => ref.name === name)) return + refs = refs.map((ref) => (ref.name === name ? { name, version } : ref)) + mutateProjectRefs(state, () => refs) + const installed = poolToNarrow(state) + state.libraries.system = effectivePool(installed, refs) + state.enabledLibraries = computeEnabled(state.libraries.system, refs) + state.missingLibraries = computeMissing(state.libraries.system, refs) + state.outdatedLibraries = computeOutdated(installed, refs) }), ) }, @@ -171,6 +195,69 @@ const createLibrarySlice: StateCreator = (se } } +/** + * What to narrow the project's view from. + * + * `installedLibraries` is empty until the pool has been hydrated, and + * narrowing an empty list would blank `libraries.system` -- which drops every + * library block's type and marks its instance variable unresolvable. Fall back + * to the pool already on show, which for one version per library is the same + * list. + */ +function poolToNarrow(state: LibrarySlice): SystemLibrary[] { + return state.installedLibraries.length > 0 ? state.installedLibraries : state.libraries.system +} + +/** Installed libraries grouped by name, insertion order preserved. */ +function groupByName(installed: SystemLibrary[]): Map { + const byName = new Map() + for (const library of installed) { + const found = byName.get(library.name) + if (found) found.push(library) + else byName.set(library.name, [library]) + } + return byName +} + +const newestFirst = (libraries: SystemLibrary[]): SystemLibrary[] => + [...libraries].sort((a, b) => compareSemver(b.version, a.version)) + +/** + * One library per name: the version the project pins, or the newest installed. + * + * Two versions of one library cannot share a compile, and placing a block from + * one while building against the other is the same mistake in the editor, so + * the pool the UI sees is narrowed the same way the compile is. + */ +function effectivePool(installed: SystemLibrary[], refs: LibraryProjectRef[]): SystemLibrary[] { + const pinned = new Map(refs.map((ref) => [ref.name, ref.version])) + const out: SystemLibrary[] = [] + for (const [name, versions] of groupByName(installed)) { + if (versions.length === 1) { + out.push(versions[0]) + continue + } + const want = pinned.get(name) + out.push(versions.find((library) => library.version === want) ?? newestFirst(versions)[0]) + } + return out +} + +/** Libraries the project pins below a version it already has installed. */ +function computeOutdated(installed: SystemLibrary[], refs: LibraryProjectRef[]): OutdatedLibrary[] { + const byName = groupByName(installed) + const outdated: OutdatedLibrary[] = [] + for (const ref of refs) { + const versions = byName.get(ref.name) + if (!versions || !ref.version) continue + const available = newestFirst(versions).map((library) => library.version) + if (compareSemver(available[0], ref.version) > 0) { + outdated.push({ name: ref.name, pinned: ref.version, available }) + } + } + return outdated +} + function computeEnabled(pool: LibrarySlice['libraries']['system'], refs: LibraryProjectRef[]): string[] { const poolNames = new Set(pool.map((lib) => lib.name)) return refs.filter((r) => poolNames.has(r.name)).map((r) => r.name) diff --git a/src/frontend/store/slices/library/types.ts b/src/frontend/store/slices/library/types.ts index 1f759c6a7..a3f82f024 100644 --- a/src/frontend/store/slices/library/types.ts +++ b/src/frontend/store/slices/library/types.ts @@ -35,6 +35,18 @@ export type MissingLibrary = { version?: string } +/** + * A library the project pins below a version already installed here. + * Drives the update prompt. + */ +export type OutdatedLibrary = { + name: string + /** Version the project's `libraries[]` records. */ + pinned: string + /** Every installed version, newest first. */ + available: string[] +} + export type LibraryActions = { setSystemLibraries: (libraries: SystemLibrary[]) => void addLibrary: (name: string, type: 'function' | 'function-block') => void @@ -66,6 +78,13 @@ export type LibraryActions = { * `project.libraries` field. */ setProjectLibraries: (refs: LibraryProjectRef[]) => void + /** + * Pin an enabled library to one of its installed versions. No-op + * when the project does not reference the library. Re-derives the + * effective pool, so placed blocks and the compile see the same + * version. + */ + setLibraryVersion: (name: string, version: string) => void /** * Record which names in the system pool are bundled / canonical. * Called by the app's library hydration after `listInstalled()` — @@ -76,6 +95,10 @@ export type LibraryActions = { } export type LibrarySliceExtra = { + /** Every installed library, one entry per installed VERSION. The + * per-project view is `libraries.system`, narrowed from this by the + * project's pins. */ + installedLibraries: SystemLibrary[] /** Names of non-bundled libraries enabled for the current project. * Bundled libraries are always-on and intentionally not tracked * here. */ @@ -88,6 +111,8 @@ export type LibrarySliceExtra = { /** Project-referenced libraries the system pool currently can't * resolve. Drives the missing-libraries modal post-project-open. */ missingLibraries: MissingLibrary[] + /** Project-pinned libraries with a newer version installed. */ + outdatedLibraries: OutdatedLibrary[] } export type LibrarySlice = import('../../../../middleware/shared/ports/library-types').LibraryState & diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index bae779f5f..11f6a5e4e 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -25,6 +25,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'debugger-message', 'debugger-ip-input', 'missing-libraries', + 'library-updates', 'public-catalog-browser', 'confirm-install-libraries', 'project-readme', diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 9a2f4336f..d47b1666b 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -30,6 +30,8 @@ export type ModalTypes = | 'debugger-message' | 'debugger-ip-input' | 'missing-libraries' + /** Offer newer installed versions of the libraries this project pins. */ + | 'library-updates' /** Browse the autonomy-edge public library catalog. Replaces the * "Coming Soon" placeholder in the Library Manager's System tab. * Multi-select; the install confirmation pops up on top. */ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 8c1d7e58c..2aa443ca2 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -13,7 +13,11 @@ import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../util import { isLegalIdentifier } from '../../../utils/keywords' import { newUuid } from '../../../utils/new-uuid' import { findGlobalVariableListReferences } from '../../../utils/PLC/global-variable-list-references' -import { restampFlowBlockVariants } from '../../../utils/PLC/restamp-block-variants' +import { + type RestampChange, + restampFlowLibraryVariants, + summariseRestampChanges, +} from '../../../utils/PLC/restamp-library-variants' import { normalizeOneVariablePerLine } from '../../../utils/PLC/variable-declarations' import { carryEditorMetadata } from '../../../utils/PLC/variable-metadata' import { generateUniqueSlaveName, type NameTaken } from '../../../utils/unique-slave-name' @@ -1013,9 +1017,19 @@ const createSharedSlice: StateCreator = (s const systemLibraries = getState().libraries.system const userPous = pous.filter((pou) => pou.pouType !== 'program') const userPouNames = userPous.map((pou) => pou.name.toUpperCase()) - let restampedCount = 0 - // Blocks still on the old two-sided VAR_IN_OUT pin are counted, never converted: the fix belongs to the - // block's update badge, and only project-owned blocks can show one. + const restampChanges: RestampChange[] = [] + let restampPoolEmpty = false + let restampModified = false + // POUs holding a block still drawn with the old two-sided VAR_IN_OUT pin. Counted, never + // converted: the fix rewires the diagram, so it belongs to the block's update badge and + // not to project load. Reporting it here is the only signal the user would otherwise get, + // since the badge itself needs a hover to appear. + // + // Split by whether the block is backed by a POU in this project, because only those can + // actually be converted: the update badge resolves a block's interface through + // `libraries.user`, so a block provided by a library (oscat-basic's LIST_*, softmotion's + // MC_* Axis pins) has no badge and stays as it is. Promising a badge that will not appear + // would be worse than saying nothing. const convertibleInOutPous = new Set() const libraryInOutBlocks = new Set() @@ -1033,25 +1047,54 @@ const createSharedSlice: StateCreator = (s // The loaded project data is frozen, so clone before re-stamping // (which mutates variant types in place) and hand the store the copy. const bodyValue = structuredClone(pou.body.value) as LadderFlowType - restampedCount += restampFlowBlockVariants([bodyValue], systemLibraries, userPous) + const report = restampFlowLibraryVariants([bodyValue], systemLibraries, userPous, { pou: pou.name }) + restampChanges.push(...report.changes) + restampPoolEmpty = restampPoolEmpty || report.poolEmpty for (const rung of bodyValue.rungs ?? []) scanLegacyInOut(rung.nodes, pou.name) getState().ladderFlowActions.addLadderFlow({ ...bodyValue, name: pou.name }) + // The canvas reads the flow, but everything that persists or compiles + // reads `pou.body.value`. Without this the refresh is thrown away on + // save and re-reported on every open. + if (report.modified) { + restampModified = true + getState().projectActions.updatePou({ name: pou.name, content: { language: 'ld', value: bodyValue } }) + } } if (pou.body.language === 'fbd') { const bodyValue = structuredClone(pou.body.value) as FBDFlowType - restampedCount += restampFlowBlockVariants([bodyValue], systemLibraries, userPous) + const report = restampFlowLibraryVariants([bodyValue], systemLibraries, userPous, { pou: pou.name }) + restampChanges.push(...report.changes) + restampPoolEmpty = restampPoolEmpty || report.poolEmpty scanLegacyInOut(bodyValue.rung?.nodes, pou.name) getState().fbdFlowActions.addFBDFlow({ ...bodyValue, name: pou.name }) + if (report.modified) { + restampModified = true + getState().projectActions.updatePou({ name: pou.name, content: { language: 'fbd', value: bodyValue } }) + } } }) - if (restampedCount > 0) { + // An empty pool means nothing could be checked -- say so rather than + // reporting a clean project. + if (restampPoolEmpty) { getState().consoleActions.addLog({ - level: 'info', - message: `Refreshed ${restampedCount} block pin type(s) from the current definitions.`, + level: 'warning', + message: + 'No libraries were loaded when this project opened, so its placed library blocks were not ' + + 'checked against their definitions.', }) } + for (const line of summariseRestampChanges(restampChanges)) { + getState().consoleActions.addLog({ level: line.severity, message: line.message }) + } + + // The flows in the store now differ from the files on disk. Saving + // writes every POU, so one save persists the refresh. + if (restampModified) { + getState().workspaceActions.setEditingState('unsaved') + } + if (convertibleInOutPous.size > 0) { getState().consoleActions.addLog({ level: 'warning', @@ -1090,6 +1133,10 @@ const createSharedSlice: StateCreator = (s if (getState().missingLibraries.length > 0) { getState().modalActions.openModal('missing-libraries') + } else if (getState().outdatedLibraries.length > 0) { + // Only when nothing is missing: installing what the project cannot + // resolve comes before choosing between versions it can. + getState().modalActions.openModal('library-updates') } // Set device definitions. diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index d1eac23ba..49ee48c2e 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: 'persistent-storage' } | { type: 'ethercat-device'; busName: string; deviceId: string } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index 201818d22..683e6af3a 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -149,6 +149,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 @@ -208,6 +218,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 'persistent-storage': @@ -218,6 +230,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 0b494e133..6437c9f4c 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -41,6 +41,7 @@ export type WorkspaceProjectTreeLeafType = | 'package-manager' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'persistent-storage' | 'ethercat-device' 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 baedc0555..4606e3f7a 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, mapArrayElementTypeToIEC, mapBaseTypeToIEC, mapUserTypeToIEC, @@ -309,6 +310,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') @@ -357,6 +409,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__/library-block-divergence.test.ts b/src/frontend/utils/PLC/__tests__/library-block-divergence.test.ts new file mode 100644 index 000000000..3dde308dc --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/library-block-divergence.test.ts @@ -0,0 +1,116 @@ +/** + * The case this exists for: a library gains a pin, and the block already on the + * canvas is still drawing the old set. + * + * Project load detects that and deliberately does not apply it — growing a + * block needs handles rebuilt, which load will not do — so the only way it + * reaches the diagram is the update badge, and the badge only appears when this + * says the block has diverged. Before this, a library block was never checked + * at all: the editors only compared blocks backed by a POU in the project. + */ + +import type { BlockVariant } from '@root/middleware/shared/ports/block-types' +import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' + +import { findLibraryPou, libraryVariantDiverges } from '../library-block-divergence' + +const pin = (name: string, cls: string) => ({ + name, + class: cls, + type: { definition: 'base-type', value: 'BOOL' }, +}) + +const libraryPou = (...pins: ReturnType[]) => + ({ name: 'SLEEP', type: 'function-block', language: 'st', body: '', documentation: '', variables: pins }) as never + +const library = (pou: ReturnType): SystemLibrary => + ({ name: 'node-uio', author: '', version: '0.0.1', stPath: '', cPath: '', pous: [pou] }) as never + +const placed = (...pins: ReturnType[]) => + ({ name: 'SLEEP', type: 'function-block', variables: pins }) as unknown as BlockVariant + +describe('finding the library a placed block came from', () => { + it('resolves a block to its library POU', () => { + const pou = libraryPou(pin('TRIGGER', 'input')) + expect(findLibraryPou(placed(), [library(pou)], [])).toBe(pou) + }) + + it('leaves a project POU alone, because the project owns its interface', () => { + const pou = libraryPou(pin('TRIGGER', 'input')) + expect(findLibraryPou(placed(), [library(pou)], ['SLEEP'])).toBeNull() + }) + + it('answers null when no library declares it', () => { + expect(findLibraryPou(placed(), [], [])).toBeNull() + }) + + it('treats a nameless variant as not a library block rather than throwing', () => { + // A node can carry a variant with no name. The comparison this replaced + // never reached for the name, so it tolerated one; opening a real project + // is what found that out. + const pou = libraryPou(pin('TRIGGER', 'input')) + const nameless = { type: 'function-block', variables: [] } as unknown as BlockVariant + expect(() => findLibraryPou(nameless, [library(pou)], [])).not.toThrow() + expect(findLibraryPou(nameless, [library(pou)], [])).toBeNull() + }) + + it('survives a library POU with no name', () => { + const broken = { + name: 'node-uio', + author: '', + version: '0.0.1', + stPath: '', + cPath: '', + pous: [{ variables: [] }], + } as unknown as SystemLibrary + expect(() => findLibraryPou(placed(), [broken], [])).not.toThrow() + }) +}) + +describe('has the placed block drifted from the library', () => { + it('says no when the pins match', () => { + const pou = libraryPou(pin('TRIGGER', 'input'), pin('ERROR', 'output')) + expect(libraryVariantDiverges(placed(pin('TRIGGER', 'input'), pin('ERROR', 'output')), pou)).toBe(false) + }) + + it('says yes when the library ADDED a pin', () => { + // HAT_IN_SLEEP and LED_IN_SLEEP, exactly. + const pou = libraryPou(pin('TRIGGER', 'input'), pin('HAT_IN_SLEEP', 'input')) + expect(libraryVariantDiverges(placed(pin('TRIGGER', 'input')), pou)).toBe(true) + }) + + it('says yes when the library REMOVED a pin', () => { + const pou = libraryPou(pin('TRIGGER', 'input')) + expect(libraryVariantDiverges(placed(pin('TRIGGER', 'input'), pin('GONE', 'input')), pou)).toBe(true) + }) + + it('says yes when a pin changed side', () => { + // A pin that moved from input to output invalidates whatever was wired to + // it, so it has to surface rather than quietly redraw. + const pou = libraryPou(pin('Q', 'output')) + expect(libraryVariantDiverges(placed(pin('Q', 'input')), pou)).toBe(true) + }) + + it('ignores EN, ENO and OUT, which a library POU never declares', () => { + const pou = libraryPou(pin('TRIGGER', 'input')) + const drawn = placed(pin('EN', 'input'), pin('ENO', 'output'), pin('TRIGGER', 'input')) + expect(libraryVariantDiverges(drawn, pou)).toBe(false) + }) + + it('ignores a local, which is never drawn as a pin', () => { + const pou = libraryPou(pin('TRIGGER', 'input'), pin('lastTrigger', 'local')) + expect(libraryVariantDiverges(placed(pin('TRIGGER', 'input')), pou)).toBe(false) + }) + + it('ignores a pin with no name on either side', () => { + const pou = libraryPou(pin('TRIGGER', 'input'), { class: 'input' } as never) + const drawn = placed(pin('TRIGGER', 'input'), { class: 'output' } as never) + expect(() => libraryVariantDiverges(drawn, pou)).not.toThrow() + expect(libraryVariantDiverges(drawn, pou)).toBe(false) + }) + + it('compares names without regard to case, as IEC does', () => { + const pou = libraryPou(pin('Trigger', 'input')) + expect(libraryVariantDiverges(placed(pin('TRIGGER', 'input')), pou)).toBe(false) + }) +}) 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 86e2c5db6..021811375 100644 --- a/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts +++ b/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts @@ -23,12 +23,36 @@ 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. `stopAtIndex` is what keeps the + // scan out of the body, so it is passed the way the callers pass it. + const content = [ + 'VAR', + ' FB0 : 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 bodyStart = findGraphicalBodyStartIndex(content, 0) + const idx = findLastEndVarIndex(content, content.indexOf('VAR'), bodyStart) + // 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', () => { @@ -92,6 +116,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__/project-libraries-json.test.ts b/src/frontend/utils/PLC/__tests__/project-libraries-json.test.ts new file mode 100644 index 000000000..d9a4e5e32 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/project-libraries-json.test.ts @@ -0,0 +1,103 @@ +import { sortProjectLibraryRefs, withProjectLibraries } from '../project-libraries-json' + +// The library manager rewrites one field of a file every other part of the +// editor also writes. Anything this drops is data loss, so the assertions are +// mostly about what it leaves alone. + +describe('sortProjectLibraryRefs', () => { + it('sorts by name and keeps only name and version', () => { + expect( + sortProjectLibraryRefs([ + { name: 'zeta', version: '1.0.0', extra: true } as never, + { name: 'alpha', version: '0.1.0' }, + ]), + ).toEqual([ + { name: 'alpha', version: '0.1.0' }, + { name: 'zeta', version: '1.0.0' }, + ]) + }) + + it('does not mutate its input', () => { + const refs = [ + { name: 'b', version: '1' }, + { name: 'a', version: '2' }, + ] + sortProjectLibraryRefs(refs) + expect(refs.map((r) => r.name)).toEqual(['b', 'a']) + }) +}) + +describe('withProjectLibraries', () => { + it('replaces data.libraries and leaves every other key alone', () => { + const json = JSON.stringify( + { + meta: { name: 'demo', schemaVersion: 3 }, + data: { pous: [{ name: 'main' }], libraries: [{ name: 'old', version: '0.0.1' }], tasks: [] }, + somethingNewerBuildsWrite: { keep: 'me' }, + }, + null, + 2, + ) + + const result = withProjectLibraries(json, [{ name: 'node-uio', version: '0.0.2' }]) + + expect(result.ok).toBe(true) + const back = JSON.parse(result.ok ? result.json : '{}') + expect(back.data.libraries).toEqual([{ name: 'node-uio', version: '0.0.2' }]) + expect(back.data.pous).toEqual([{ name: 'main' }]) + expect(back.data.tasks).toEqual([]) + expect(back.meta).toEqual({ name: 'demo', schemaVersion: 3 }) + expect(back.somethingNewerBuildsWrite).toEqual({ keep: 'me' }) + }) + + it('writes refs sorted by name', () => { + const result = withProjectLibraries('{"data":{}}', [ + { name: 'zeta', version: '1.0.0' }, + { name: 'alpha', version: '0.1.0' }, + ]) + + expect(result.ok && JSON.parse(result.json).data.libraries.map((r: { name: string }) => r.name)).toEqual([ + 'alpha', + 'zeta', + ]) + }) + + it('clears the list when given no refs', () => { + const result = withProjectLibraries('{"data":{"libraries":[{"name":"a","version":"1"}]}}', []) + + expect(result.ok && JSON.parse(result.json).data.libraries).toEqual([]) + }) + + it('creates data when the document has none', () => { + const result = withProjectLibraries('{"meta":{"name":"demo"}}', [{ name: 'a', version: '1' }]) + + expect(result.ok && JSON.parse(result.json)).toEqual({ + meta: { name: 'demo' }, + data: { libraries: [{ name: 'a', version: '1' }] }, + }) + }) + + it('replaces a data key that is not an object', () => { + const result = withProjectLibraries('{"data":"nonsense"}', [{ name: 'a', version: '1' }]) + + expect(result.ok && JSON.parse(result.json).data).toEqual({ libraries: [{ name: 'a', version: '1' }] }) + }) + + it('reports malformed JSON rather than throwing', () => { + expect(withProjectLibraries('{ not json', [])).toEqual({ ok: false, error: 'project.json on disk is malformed' }) + }) + + it.each([ + ['an array', '[]'], + ['null', 'null'], + ['a number', '42'], + ])('reports %s as not an object', (_label, json) => { + expect(withProjectLibraries(json, [])).toEqual({ ok: false, error: 'project.json on disk is not an object' }) + }) + + it('emits two-space indented JSON, as the project writer does', () => { + const result = withProjectLibraries('{"data":{}}', [{ name: 'a', version: '1' }]) + + expect(result.ok && result.json.split('\n')[1]).toBe(' "data": {') + }) +}) diff --git a/src/frontend/utils/PLC/__tests__/restamp-block-variants.test.ts b/src/frontend/utils/PLC/__tests__/restamp-block-variants.test.ts deleted file mode 100644 index bab7a0c01..000000000 --- a/src/frontend/utils/PLC/__tests__/restamp-block-variants.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { PLCVariable } from '../../../../middleware/shared/ports/types' -import type { SystemLibrary } from '../../../../middleware/shared/ports/library-types' -import { syncNodesWithVariables } from '../../graphical/sync-nodes-with-variables' -import { restampFlowBlockVariants } from '../restamp-block-variants' - -// --------------------------------------------------------------------------- -// Factory helpers -// --------------------------------------------------------------------------- - -/** A system library whose ADR function now returns __XWORD (was ULINT). */ -function makeSystemLibraries(): SystemLibrary[] { - return [ - { - name: 'STANDARD_FUNCTIONS', - pous: [ - { - name: 'ADR', - type: 'function', - language: 'st', - body: '', - documentation: '', - variables: [ - { name: 'OUT', class: 'output', type: { definition: 'base-type', value: '__XWORD' } }, - { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, - ], - }, - ], - }, - ] as unknown as SystemLibrary[] -} - -/** A block node whose ADR variant is still stamped with the old ULINT return. */ -function makeStaleAdrNode() { - return { - id: 'block-1', - type: 'block', - data: { - variant: { - name: 'ADR', - type: 'function', - language: 'st', - body: '', - documentation: '', - variables: [ - { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, - { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, - ], - }, - }, - } -} - -/** A project function block whose IN1 pin is typed MyStruct. */ -function makeUserPou( - name: string, - variables: Array<{ name: string; class: string; definition: string; value: string }>, - options: { pouType?: string; returnType?: string } = {}, -) { - return { - name, - pouType: options.pouType ?? 'function-block', - body: { language: 'st', value: '' }, - interface: { - ...(options.returnType ? { returnType: options.returnType } : {}), - variables: variables.map((variable) => ({ - name: variable.name, - class: variable.class, - type: { definition: variable.definition, value: variable.value }, - })), - }, - } as unknown as Parameters[2][number] -} - -/** A placed user FB whose IN1 pin is still stamped with the old type. */ -function makeStaleUserBlockNode(pinValue = 'OLDSTRUCT') { - return { - id: 'block-1', - type: 'block', - data: { - variant: { - name: 'MyFB', - type: 'function-block', - variables: [{ name: 'IN1', class: 'input', type: { definition: 'user-data-type', value: pinValue } }], - }, - }, - } -} - -/** The ladder pin node that connects a variable to that block's IN1 pin. */ -function makePinNode(pinValue = 'OLDSTRUCT') { - return { - id: 'pin-1', - type: 'variable', - data: { - variable: { name: 'motor' }, - variant: 'input', - block: { - id: 'block-1', - handleId: 'IN1', - variableType: { name: 'IN1', class: 'input', type: { definition: 'user-data-type', value: pinValue } }, - }, - }, - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('restampFlowBlockVariants', () => { - it('refreshes a stale library block return type (ADR ULINT -> __XWORD)', () => { - const node = makeStaleAdrNode() - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(1) - expect(node.data.variant.variables.find((v) => v.name === 'OUT')!.type.value).toBe('__XWORD') - }) - - it('walks every rung of a ladder flow', () => { - const node = makeStaleAdrNode() - const flow = { rungs: [{ nodes: [] }, { nodes: [node] }] } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type.value).toBe('__XWORD') - }) - - it('lets a user-defined POU win over a library entry of the same name', () => { - // A user POU named "ADR" (contrived): the project owns its own interface, - // so its return type is stamped, not the library's __XWORD. - const node = makeStaleAdrNode() - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), [ - makeUserPou('ADR', [], { pouType: 'function', returnType: 'DINT' }), - ]) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type.value).toBe('DINT') - }) - - it('leaves up-to-date variants untouched (no spurious changes)', () => { - const node = makeStaleAdrNode() - node.data.variant.variables[0].type.value = '__XWORD' - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(0) - }) - - it('ignores blocks not present in any library (user blocks, unknown types)', () => { - const node = makeStaleAdrNode() - node.data.variant.name = 'MY_CUSTOM_FB' - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(0) - expect(node.data.variant.variables[0].type.value).toBe('ULINT') - }) - - it('is a no-op when neither a library nor a project POU defines the block', () => { - const node = makeStaleAdrNode() - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], [], []) - - expect(changed).toBe(0) - expect(node.data.variant.variables[0].type.value).toBe('ULINT') - }) -}) - -describe('restampFlowBlockVariants — blocks backed by a project POU', () => { - it('refreshes a stale user function-block pin type from the POU interface', () => { - const node = makeStaleUserBlockNode() - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type).toEqual({ definition: 'user-data-type', value: 'MYSTRUCT' }) - }) - - it("follows the POU's return type for a function's OUT pin", () => { - const node = makeStaleUserBlockNode() - node.data.variant.name = 'MyFn' - node.data.variant.type = 'function' - node.data.variant.variables = [ - { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'INT' } }, - ] as unknown as typeof node.data.variant.variables - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFn', [], { pouType: 'function', returnType: 'REAL' })], - ) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type.value).toBe('REAL') - }) - - it('leaves pins the interface no longer declares alone (EN/ENO, removed pins)', () => { - const node = makeStaleUserBlockNode() - node.data.variant.variables = [ - { name: 'EN', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, - { name: 'GONE', class: 'input', type: { definition: 'base-type', value: 'INT' } }, - ] as unknown as typeof node.data.variant.variables - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'base-type', value: 'REAL' }])], - ) - - expect(changed).toBe(0) - expect(node.data.variant.variables.map((variable) => variable.name)).toEqual(['EN', 'GONE']) - }) - - it('never adds a pin the interface gained (that needs the node rebuilt)', () => { - const node = makeStaleUserBlockNode() - const flow = { rung: { nodes: [node] } } - - restampFlowBlockVariants( - [flow], - [], - [ - makeUserPou('MyFB', [ - { name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }, - { name: 'IN2', class: 'input', definition: 'base-type', value: 'INT' }, - ]), - ], - ) - - expect(node.data.variant.variables).toHaveLength(1) - }) - - it('matches the POU name case-insensitively, as IEC identifiers are', () => { - const node = makeStaleUserBlockNode() - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('myfb', [{ name: 'in1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type.value).toBe('MYSTRUCT') - }) - - it('leaves an up-to-date user block untouched', () => { - const node = makeStaleUserBlockNode('MYSTRUCT') - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ) - - expect(changed).toBe(0) - }) -}) - -describe('restampFlowBlockVariants — malformed or oddly cased data', () => { - it('matches a library POU name case-insensitively', () => { - const node = makeStaleAdrNode() - node.data.variant.name = 'adr' - const flow = { rung: { nodes: [node] } } - - const changed = restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(1) - expect(node.data.variant.variables[0].type.value).toBe('__XWORD') - }) - - it('skips a block whose persisted variant has no variables array', () => { - const node = makeStaleAdrNode() - delete (node.data.variant as { variables?: unknown }).variables - const flow = { rung: { nodes: [node] } } - - expect(() => restampFlowBlockVariants([flow], makeSystemLibraries(), [])).not.toThrow() - }) - - it('skips a pin node whose block variant has no variables array', () => { - const blockNode = makeStaleUserBlockNode() - delete (blockNode.data.variant as { variables?: unknown }).variables - const pinNode = makePinNode() - const flow = { rung: { nodes: [blockNode, pinNode] } } - - expect(() => - restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ), - ).not.toThrow() - expect(pinNode.data.block.variableType.type.value).toBe('OLDSTRUCT') - }) -}) - -describe('restampFlowBlockVariants — ladder pin nodes', () => { - it("refreshes the pin node's cached type from the block it connects to", () => { - const pinNode = makePinNode() - const flow = { rung: { nodes: [makeStaleUserBlockNode(), pinNode] } } - - restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ) - - expect(pinNode.data.block.variableType.type).toEqual({ definition: 'user-data-type', value: 'MYSTRUCT' }) - }) - - it('refreshes a pin node whose block is a library block', () => { - const blockNode = makeStaleAdrNode() - const pinNode = makePinNode('ULINT') - pinNode.data.block.handleId = 'OUT' - pinNode.data.block.variableType = { - name: 'OUT', - class: 'output', - type: { definition: 'base-type', value: 'ULINT' }, - } as unknown as typeof pinNode.data.block.variableType - const flow = { rung: { nodes: [blockNode, pinNode] } } - - restampFlowBlockVariants([flow], makeSystemLibraries(), []) - - expect(pinNode.data.block.variableType.type.value).toBe('__XWORD') - }) - - it('leaves a pin node whose block is not in the rung alone', () => { - const pinNode = makePinNode() - const flow = { rung: { nodes: [pinNode] } } - - const changed = restampFlowBlockVariants( - [flow], - [], - [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], - ) - - expect(changed).toBe(0) - expect(pinNode.data.block.variableType.type.value).toBe('OLDSTRUCT') - }) -}) - -describe('DOPE-548 — a user FB pin type change must not break a linked variable', () => { - it('leaves the link intact once the block and its pin node are re-stamped', () => { - const flow = { - name: 'Prog', - rungs: [{ id: 'r1', nodes: [makeStaleUserBlockNode(), makePinNode()], edges: [] }], - } - // The FB now declares IN1 : MyStruct, and the POU variable follows it. - const userPous = [ - makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }]), - ] - const variables = [ - { id: '1', name: 'motor', type: { definition: 'user-data-type', value: 'MYSTRUCT' } }, - ] as unknown as PLCVariable[] - - restampFlowBlockVariants([flow], [], userPous) - - const updateNodes = vi.fn() - syncNodesWithVariables(variables, [flow] as unknown as Parameters[1], updateNodes) - - // Without the re-stamp the pin still reads OLDSTRUCT and the node is - // replaced by a broken-… payload flagged wrongVariable. - expect(updateNodes).not.toHaveBeenCalled() - }) -}) diff --git a/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts new file mode 100644 index 000000000..59a1b90c7 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts @@ -0,0 +1,895 @@ +import { produce } from 'immer' + +import type { SystemLibrary } from '../../../../middleware/shared/ports/library-types' +import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { syncNodesWithVariables } from '../../graphical/sync-nodes-with-variables' +import { type RestampChange, restampFlowLibraryVariants, summariseRestampChanges } from '../restamp-library-variants' + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- + +type LibVar = { name: string; class: string; type: { definition: string; value: string } } + +/** A system library whose ADR function now returns __XWORD (was ULINT). */ +function makeSystemLibraries(overrides: Partial> = {}): SystemLibrary[] { + return [ + { + name: 'STANDARD_FUNCTIONS', + pous: [ + { + name: 'ADR', + type: 'function', + language: 'st', + body: '', + documentation: '', + variables: [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: '__XWORD' } }, + { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, + ] as LibVar[], + ...overrides, + }, + ], + }, + ] as unknown as SystemLibrary[] +} + +/** A block node whose ADR variant is still stamped with the old ULINT return. */ +function makeStaleAdrNode() { + return { + id: 'block-1', + type: 'block', + data: { + variable: { name: 'ADR0' }, + variant: { + name: 'ADR', + type: 'function', + language: 'st', + body: '', + documentation: '', + variables: [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, + ], + }, + }, + } +} + +/** The same library, plus a MODE pin ADR does not have yet. */ +function grownLibrary(): SystemLibrary[] { + return makeSystemLibraries({ + variables: [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, + { name: 'MODE', class: 'input', type: { definition: 'derived-type', value: 'UIO_MODE' } }, + ], + }) +} + +/** A placed block carrying the connector geometry a re-measure anchors on. */ +function measurableNode() { + const node = makeStaleAdrNode() as ReturnType & { + width?: number + measured?: { width: number; height: number } + data: { inputConnector?: { glbPosition: { x: number; y: number } }; handles?: unknown[] } + } + node.data.inputConnector = { glbPosition: { x: 10, y: 20 } } + return node +} + +/** One of the project's own POUs, as the store holds it. */ +function makeUserPou( + name: string, + variables: Array<{ name: string; class: string; definition: string; value: string }>, + options: { pouType?: string; returnType?: string } = {}, +) { + return { + name, + pouType: options.pouType ?? 'function-block', + body: { language: 'st', value: '' }, + interface: { + ...(options.returnType ? { returnType: options.returnType } : {}), + variables: variables.map((variable) => ({ + name: variable.name, + class: variable.class, + type: { definition: variable.definition, value: variable.value }, + })), + }, + } as unknown as Parameters[2][number] +} + +/** A placed user FB whose IN1 pin is still stamped with the old type. */ +function makeStaleUserBlockNode(pinValue = 'OLDSTRUCT') { + return { + id: 'block-1', + type: 'block', + data: { + variant: { + name: 'MyFB', + type: 'function-block', + variables: [{ name: 'IN1', class: 'input', type: { definition: 'user-data-type', value: pinValue } }], + }, + }, + } +} + +/** The ladder pin node that connects a variable to that block's IN1 pin. */ +function makePinNode(pinValue = 'OLDSTRUCT') { + return { + id: 'pin-1', + type: 'variable', + data: { + variable: { name: 'motor' }, + variant: 'input', + block: { + id: 'block-1', + handleId: 'IN1', + variableType: { name: 'IN1', class: 'input', type: { definition: 'user-data-type', value: pinValue } }, + }, + }, + } +} + +const applied = (changes: RestampChange[]) => changes.filter((change) => change.applied) +const kindOf = (changes: RestampChange[], kind: string) => changes.find((change) => change.kind === kind) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('restampFlowLibraryVariants', () => { + it('refreshes a stale library block return type (ADR ULINT -> __XWORD)', () => { + const node = makeStaleAdrNode() + const flow = { rung: { nodes: [node] } } + + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) + + expect(applied(changes)).toHaveLength(1) + expect(kindOf(changes, 'type')).toMatchObject({ block: 'ADR', pin: 'OUT', from: 'ULINT', to: '__XWORD' }) + expect(node.data.variant.variables.find((v) => v.name === 'OUT')!.type.value).toBe('__XWORD') + }) + + it('walks every rung of a ladder flow', () => { + const node = makeStaleAdrNode() + const flow = { rungs: [{ nodes: [] }, { nodes: [node] }] } + + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) + + expect(applied(changes)).toHaveLength(1) + expect(node.data.variant.variables[0].type.value).toBe('__XWORD') + }) + + it('lets a user-defined POU win over a library block of the same name', () => { + // The project owns its own interface, so the library definition does not + // reach a block the project also defines. + const node = makeStaleAdrNode() + const flow = { rung: { nodes: [node] } } + + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), [ + makeUserPou('ADR', [{ name: 'OUT', class: 'output', definition: 'base-type', value: 'DINT' }]), + ]) + + expect(changes).toHaveLength(0) + expect(node.data.variant.variables[0].type.value).toBe('DINT') + }) + + it('reports nothing for a user POU, whose interface the user just edited', () => { + const node = makeStaleAdrNode() + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants([flow], makeSystemLibraries(), [ + makeUserPou('ADR', [{ name: 'OUT', class: 'output', definition: 'base-type', value: 'DINT' }]), + ]) + + // Silent, but still persisted -- otherwise the refresh is redone on every load. + expect(report.changes).toHaveLength(0) + expect(report.modified).toBe(true) + }) + + it('leaves up-to-date variants untouched (no spurious changes)', () => { + const node = makeStaleAdrNode() + node.data.variant.variables[0].type.value = '__XWORD' + const flow = { rung: { nodes: [node] } } + + expect(restampFlowLibraryVariants([flow], makeSystemLibraries(), []).changes).toHaveLength(0) + }) + + it('ignores blocks not present in any library (user blocks, unknown types)', () => { + const node = makeStaleAdrNode() + node.data.variant.name = 'MY_CUSTOM_FB' + const flow = { rung: { nodes: [node] } } + + expect(restampFlowLibraryVariants([flow], makeSystemLibraries(), []).changes).toHaveLength(0) + expect(node.data.variant.variables[0].type.value).toBe('ULINT') + }) + + it('reports an empty pool rather than a clean project', () => { + const node = makeStaleAdrNode() + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants([flow], [], []) + + expect(report.poolEmpty).toBe(true) + expect(report.changes).toHaveLength(0) + expect(node.data.variant.variables[0].type.value).toBe('ULINT') + }) + + it('refreshes stale documentation', () => { + const node = makeStaleAdrNode() + node.data.variant.documentation = 'the old wording' + const libraries = makeSystemLibraries({ documentation: 'the new wording' }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + expect(kindOf(changes, 'documentation')).toMatchObject({ severity: 'info', applied: true }) + expect(node.data.variant.documentation).toBe('the new wording') + }) + + it('refreshes extensible, but only reports a change of block kind', () => { + const node = makeStaleAdrNode() + const libraries = makeSystemLibraries({ extensible: true, type: 'function-block' }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + expect(kindOf(changes, 'extensible')).toMatchObject({ from: 'false', to: 'true', applied: true }) + // Applying it would leave a function block with no instance variable. + expect(kindOf(changes, 'block-type')).toMatchObject({ + from: 'function', + to: 'function-block', + applied: false, + severity: 'error', + }) + expect(node.data.variant.type).toBe('function') + }) + + it("leaves an extensible block's extra pins alone", () => { + const node = makeStaleAdrNode() + node.data.variant.variables.push({ + name: 'IN2', + class: 'input', + type: { definition: 'generic-type', value: 'ANY' }, + }) + const libraries = makeSystemLibraries({ extensible: true }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + // IN2 is the diagram's, not the library's: a variadic block grows past its + // declared parameters. + expect(kindOf(changes, 'pin-removed')).toBeUndefined() + expect(node.data.variant.variables.map((v) => v.name)).toContain('IN2') + }) + + it('applies a class change that keeps the pin on the same side', () => { + const node = makeStaleAdrNode() + const libraries = makeSystemLibraries({ + variables: [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + { name: 'IN', class: 'inOut', type: { definition: 'generic-type', value: 'ANY' } }, + ], + }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + expect(kindOf(changes, 'class')).toMatchObject({ pin: 'IN', from: 'input', to: 'inOut', applied: true }) + expect(node.data.variant.variables[1].class).toBe('inOut') + }) + + it('reports without applying a class change that moves the pin to the other side', () => { + const node = makeStaleAdrNode() + const libraries = makeSystemLibraries({ + variables: [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + { name: 'IN', class: 'output', type: { definition: 'generic-type', value: 'ANY' } }, + ], + }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + expect(kindOf(changes, 'pin-side')).toMatchObject({ pin: 'IN', applied: false, severity: 'warning' }) + expect(node.data.variant.variables[1].class).toBe('input') + }) + + it('reports a removed pin as an error when something is wired to it', () => { + const node = makeStaleAdrNode() + const libraries = makeSystemLibraries({ + variables: [{ name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }], + }) + const flow = { + rung: { + nodes: [node], + edges: [{ source: 'var-1', target: 'block-1', sourceHandle: 'out', targetHandle: 'IN' }], + }, + } + + const { changes } = restampFlowLibraryVariants([flow], libraries, []) + + expect(kindOf(changes, 'pin-removed')).toMatchObject({ + pin: 'IN', + severity: 'error', + applied: false, + connected: true, + }) + // Not applied: the pin stays until the node's handles can be rebuilt. + expect(node.data.variant.variables).toHaveLength(2) + }) + + it('reports a removed pin as a warning when nothing is wired to it', () => { + const node = makeStaleAdrNode() + const libraries = makeSystemLibraries({ + variables: [{ name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }], + }) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], libraries, []) + + expect(kindOf(changes, 'pin-removed')).toMatchObject({ pin: 'IN', severity: 'warning', connected: false }) + }) + + it('treats a variable bound to a handle as a connection', () => { + const node = makeStaleAdrNode() as ReturnType & { + data: { connectedVariables?: Array<{ handleId: string }> } + } + node.data.connectedVariables = [{ handleId: 'OUT' }] + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], makeSystemLibraries(), []) + + expect(kindOf(changes, 'type')).toMatchObject({ severity: 'warning', connected: true }) + }) + + it('reports a pin the library added, and leaves the block alone without a measurer', () => { + const node = makeStaleAdrNode() + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], grownLibrary(), []) + + expect(kindOf(changes, 'pin-added')).toMatchObject({ pin: 'MODE', to: 'UIO_MODE', applied: false }) + expect(node.data.variant.variables.map((v) => v.name)).toEqual(['OUT', 'IN']) + }) + + it('grows the block onto an added pin when a measurer is supplied', () => { + const node = measurableNode() + const measureBlock = jest.fn(() => ({ + handles: ['h'], + leftHandles: [{ glbPosition: { x: 5, y: 6 } }], + rightHandles: [{ glbPosition: { x: 9, y: 6 } }], + width: 120, + height: 80, + })) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], grownLibrary(), [], { + measureBlock, + }) + + expect(kindOf(changes, 'pin-added')).toMatchObject({ pin: 'MODE', applied: true, severity: 'info' }) + expect(node.data.variant.variables.map((v) => v.name)).toEqual(['OUT', 'IN', 'MODE']) + // Measured from the connector the block already had, so it does not move. + expect(measureBlock).toHaveBeenCalledWith(node.data.variant, { x: 10, y: 20 }) + expect(node.data.handles).toEqual(['h']) + expect(node.data.inputConnector).toEqual({ glbPosition: { x: 5, y: 6 } }) + expect(node.width).toBe(120) + expect(node.measured).toEqual({ width: 120, height: 80 }) + }) + + it('does not grow a block that has no connector to measure from', () => { + const node = makeStaleAdrNode() + const measureBlock = jest.fn() + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], grownLibrary(), [], { + measureBlock, + }) + + expect(measureBlock).not.toHaveBeenCalled() + expect(kindOf(changes, 'pin-added')).toMatchObject({ applied: false }) + expect(node.data.variant.variables.map((v) => v.name)).toEqual(['OUT', 'IN']) + }) + + it('ignores the implicit EN/ENO pins', () => { + const node = makeStaleAdrNode() + node.data.variant.variables.push( + { name: 'EN', class: 'input', type: { definition: 'generic-type', value: 'BOOL' } }, + { name: 'ENO', class: 'output', type: { definition: 'generic-type', value: 'BOOL' } }, + ) + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], makeSystemLibraries(), []) + + expect(changes.filter((change) => change.pin === 'EN' || change.pin === 'ENO')).toHaveLength(0) + }) + + it('mutates in place, so a caller holding store state must clone first', () => { + // The store's flows come back from immer frozen. This is the contract the + // library-update action relies on: re-stamp a copy, then hand it back. + const frozen = produce({ rung: { nodes: [makeStaleAdrNode()] } }, () => {}) + + expect(() => restampFlowLibraryVariants([frozen as never], makeSystemLibraries(), [])).toThrow() + + const clone = structuredClone(frozen) as unknown as { rung: { nodes: unknown[] } } + expect(() => restampFlowLibraryVariants([clone as never], makeSystemLibraries(), [])).not.toThrow() + }) + + it('brings a variable wired to a pin along with the pin', () => { + // A variable node keeps its own copy of the pin signature. The canvas + // renders its type as the `(*TYPE*)` placeholder and validates dropped + // variables against it, so a stale copy makes the update look like it did + // nothing. + const block = makeStaleAdrNode() + const attached = { + id: 'var-node-1', + type: 'variable', + data: { + variant: 'output', + variable: { name: '' }, + block: { + id: block.id, + handleId: 'OUT', + variableType: { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + }, + }, + } + + restampFlowLibraryVariants([{ rung: { nodes: [block, attached] } }], makeSystemLibraries(), []) + + expect(block.data.variant.variables.find((v) => v.name === 'OUT')!.type.value).toBe('__XWORD') + expect(attached.data.block.variableType.type.value).toBe('__XWORD') + }) + + it('reports the flow as modified when only the wired copy was stale', () => { + // The block itself is current; only the variable's copy of the pin is + // behind. Without `modified` the correction is made in memory, never + // persisted, and redone on every load. + const block = makeStaleAdrNode() + block.data.variant.variables[0].type.value = '__XWORD' + const attached = { + id: 'var-node-1', + type: 'variable', + data: { + variant: 'output', + variable: { name: '' }, + block: { + id: block.id, + handleId: 'OUT', + variableType: { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + }, + }, + } + + const report = restampFlowLibraryVariants([{ rung: { nodes: [block, attached] } }], makeSystemLibraries(), []) + + expect(report.changes).toHaveLength(0) + expect(report.modified).toBe(true) + expect(attached.data.block.variableType.type.value).toBe('__XWORD') + }) + + it('reports nothing modified when everything already agrees', () => { + const block = makeStaleAdrNode() + block.data.variant.variables[0].type.value = '__XWORD' + + const report = restampFlowLibraryVariants([{ rung: { nodes: [block] } }], makeSystemLibraries(), []) + + expect(report.changes).toHaveLength(0) + expect(report.modified).toBe(false) + }) + + it('leaves a variable wired to a different block alone', () => { + const block = makeStaleAdrNode() + const attached = { + id: 'var-node-1', + type: 'variable', + data: { + variant: 'output', + variable: { name: '' }, + block: { + id: 'SOME_OTHER_BLOCK', + handleId: 'OUT', + variableType: { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'ULINT' } }, + }, + }, + } + + restampFlowLibraryVariants([{ rung: { nodes: [block, attached] } }], makeSystemLibraries(), []) + + expect(attached.data.block.variableType.type.value).toBe('ULINT') + }) + + it('stamps the POU name onto every change when given one', () => { + const node = makeStaleAdrNode() + + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], makeSystemLibraries(), [], { + pou: 'main', + }) + + expect(changes.every((change) => change.pou === 'main')).toBe(true) + }) +}) + +describe('FBD flows', () => { + // FBD keeps no copy of a pin: `fbd/variable.tsx` finds the connected block + // and reads `data.variant.variables` live, matching on the edge's handle, + // and builds its `(*TYPE*)` label from that. So refreshing the variant is + // the whole job -- unlike LD, which caches the pin on the variable node. + const fbdPinTypeAsTheEditorResolvesIt = ( + rung: { + nodes: { id: string; type?: string; data?: never }[] + edges: { source: string; target: string; sourceHandle?: string; targetHandle?: string }[] + }, + variableNodeId: string, + ): string | undefined => { + const edge = rung.edges.find((e) => e.source === variableNodeId || e.target === variableNodeId)! + const other = edge.source === variableNodeId ? edge.target : edge.source + const block = rung.nodes.find((n) => n.id === other && n.type === 'block')! + const variables = (block.data as never as { variant: { variables: { name: string; type: { value: string } }[] } }) + .variant.variables + return variables.find((v) => v.name === edge.sourceHandle || v.name === edge.targetHandle)?.type.value + } + + it('updates what the editor resolves for a wired pin, with no cached copy to chase', () => { + const block = makeStaleAdrNode() + const wired = { + id: 'VAR_out', + type: 'output-variable', + data: { variant: 'output-variable', variable: { name: 'X' } }, + } + const rung = { + nodes: [block, wired], + edges: [{ id: 'e1', source: block.id, target: 'VAR_out', sourceHandle: 'OUT', targetHandle: 'input-variable' }], + } + + expect(fbdPinTypeAsTheEditorResolvesIt(rung as never, 'VAR_out')).toBe('ULINT') + + const report = restampFlowLibraryVariants([{ rung } as never], makeSystemLibraries(), []) + + expect(report.modified).toBe(true) + expect(fbdPinTypeAsTheEditorResolvesIt(rung as never, 'VAR_out')).toBe('__XWORD') + // An FBD variable node has no `data.block`, so the second pass must simply + // leave it alone rather than trip over the missing field. + expect(wired.data).not.toHaveProperty('block') + }) + + it('walks an FBD flow that carries edges', () => { + const block = makeStaleAdrNode() + const flow = { rung: { nodes: [block], edges: [{ id: 'e', source: 'x', target: block.id, targetHandle: 'IN' }] } } + + expect(restampFlowLibraryVariants([flow as never], makeSystemLibraries(), []).modified).toBe(true) + }) +}) + +describe('pin type transitions', () => { + type Def = 'base-type' | 'generic-type' | 'derived-type' + + const libWithPin = (definition: Def, value: string): SystemLibrary[] => + [ + { + name: 'L', + pous: [ + { + name: 'FB', + type: 'function-block', + language: 'st', + body: '', + documentation: 'doc', + variables: [{ name: 'P', class: 'input', type: { definition, value } }], + }, + ], + }, + ] as unknown as SystemLibrary[] + + const placed = (definition: Def, value: string) => { + const block = { + id: 'b1', + type: 'block', + data: { + variable: { name: 'FB0' }, + variant: { + name: 'FB', + type: 'function-block', + documentation: 'doc', + variables: [{ name: 'P', class: 'input', type: { definition, value } }], + }, + }, + } + const wired = { + id: 'v1', + type: 'variable', + data: { + variant: 'input', + variable: { name: '' }, + block: { id: 'b1', handleId: 'P', variableType: { name: 'P', class: 'input', type: { definition, value } } }, + }, + } + return { block, wired } + } + + // Every definition pairing, plus the exotic values the real libraries use: + // an FB type passed by reference (NODE), a platform-width word, and a sized + // string — which `typeRef` classifies as derived because it is not a bare + // base-type name. + const cases: Array<[string, Def, string, Def, string]> = [ + ['base to base', 'base-type', 'INT', 'base-type', 'REAL'], + ['base to base (bit widths)', 'base-type', 'BYTE', 'base-type', 'DWORD'], + ['base to base (time)', 'base-type', 'TIME', 'base-type', 'DT'], + ['base to base (strings)', 'base-type', 'STRING', 'base-type', 'WSTRING'], + ['base to platform word', 'base-type', 'ULINT', 'base-type', '__XWORD'], + ['base to generic', 'base-type', 'INT', 'generic-type', 'ANY_NUM'], + ['generic to base', 'generic-type', 'ANY', 'base-type', 'BOOL'], + ['generic to generic', 'generic-type', 'ANY', 'generic-type', 'ANY_INT'], + ['base to derived enum', 'base-type', 'INT', 'derived-type', 'UIO_MODE'], + ['derived to derived enum', 'derived-type', 'UIO_MODE', 'derived-type', 'UIO_RESULT'], + ['derived to base', 'derived-type', 'MODBEE_RESULT', 'base-type', 'INT'], + ['derived FB reference', 'derived-type', 'NODE', 'derived-type', 'NODE_LINK'], + ['base to sized string', 'base-type', 'STRING', 'derived-type', 'STRING(23)'], + ] + + it.each(cases)('%s: %s %s -> %s %s', (_label, fromDef, fromValue, toDef, toValue) => { + const { block, wired } = placed(fromDef, fromValue) + + const report = restampFlowLibraryVariants([{ rung: { nodes: [block, wired] } }], libWithPin(toDef, toValue), [], { + pou: 'main', + }) + + expect(report.modified).toBe(true) + expect(kindOf(report.changes, 'type')).toMatchObject({ pin: 'P', from: fromValue, to: toValue, applied: true }) + // The pin, and the copy held by the variable wired to it. + expect(block.data.variant.variables[0].type).toEqual({ definition: toDef, value: toValue }) + expect(wired.data.block.variableType.type).toEqual({ definition: toDef, value: toValue }) + }) + + it('reports nothing when the definition and value both already match', () => { + const { block, wired } = placed('derived-type', 'UIO_MODE') + + const report = restampFlowLibraryVariants( + [{ rung: { nodes: [block, wired] } }], + libWithPin('derived-type', 'UIO_MODE'), + [], + ) + + expect(report.changes).toHaveLength(0) + expect(report.modified).toBe(false) + }) + + it('notices a definition change even when the value is unchanged', () => { + // STRING as a base type and STRING as a library-declared alias are not the + // same pin, and a value-only compare would miss it. + const { block, wired } = placed('base-type', 'STRING') + + const report = restampFlowLibraryVariants( + [{ rung: { nodes: [block, wired] } }], + libWithPin('derived-type', 'STRING'), + [], + ) + + expect(report.modified).toBe(true) + expect(block.data.variant.variables[0].type.definition).toBe('derived-type') + expect(wired.data.block.variableType.type.definition).toBe('derived-type') + }) +}) + +describe('summariseRestampChanges', () => { + it('collapses the same change across many placed blocks into one line', () => { + const change: RestampChange = { + block: 'ANALOG_IN', + pou: 'main', + pin: 'AI01', + kind: 'type', + from: 'INT', + to: 'REAL', + severity: 'info', + applied: true, + } + + const lines = summariseRestampChanges([change, { ...change }, { ...change }]) + + expect(lines).toHaveLength(1) + expect(lines[0].message).toBe('ANALOG_IN.AI01: type INT → REAL — 3 blocks in main.') + }) + + it('orders errors before warnings before info', () => { + const base = { block: 'B', kind: 'documentation' as const, applied: true } + const lines = summariseRestampChanges([ + { ...base, severity: 'info' }, + { ...base, block: 'A', severity: 'error' }, + { ...base, block: 'C', severity: 'warning' }, + ]) + + expect(lines.map((line) => line.severity)).toEqual(['error', 'warning', 'info']) + }) +}) + +describe('restampFlowLibraryVariants — blocks backed by a project POU', () => { + it('refreshes a stale user function-block pin type from the POU interface', () => { + const node = makeStaleUserBlockNode() + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(report.modified).toBe(true) + expect(node.data.variant.variables[0].type).toEqual({ definition: 'user-data-type', value: 'MYSTRUCT' }) + }) + + it("follows the POU's return type for a function's OUT pin", () => { + const node = makeStaleUserBlockNode() + node.data.variant.name = 'MyFn' + node.data.variant.type = 'function' + node.data.variant.variables = [ + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: 'INT' } }, + ] as unknown as typeof node.data.variant.variables + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFn', [], { pouType: 'function', returnType: 'REAL' })], + ) + + expect(report.modified).toBe(true) + expect(node.data.variant.variables[0].type.value).toBe('REAL') + }) + + it('leaves pins the interface no longer declares alone (EN/ENO, removed pins)', () => { + const node = makeStaleUserBlockNode() + node.data.variant.variables = [ + { name: 'EN', class: 'input', type: { definition: 'base-type', value: 'BOOL' } }, + { name: 'GONE', class: 'input', type: { definition: 'base-type', value: 'INT' } }, + ] as unknown as typeof node.data.variant.variables + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'base-type', value: 'REAL' }])], + ) + + expect(report.modified).toBe(false) + expect(node.data.variant.variables.map((variable) => variable.name)).toEqual(['EN', 'GONE']) + }) + + it('never adds a pin the interface gained (that needs the node rebuilt)', () => { + const node = makeStaleUserBlockNode() + const flow = { rung: { nodes: [node] } } + + restampFlowLibraryVariants( + [flow], + [], + [ + makeUserPou('MyFB', [ + { name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }, + { name: 'IN2', class: 'input', definition: 'base-type', value: 'INT' }, + ]), + ], + ) + + expect(node.data.variant.variables).toHaveLength(1) + }) + + it('matches the POU name case-insensitively, as IEC identifiers are', () => { + const node = makeStaleUserBlockNode() + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('myfb', [{ name: 'in1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(report.modified).toBe(true) + expect(node.data.variant.variables[0].type.value).toBe('MYSTRUCT') + }) + + it('leaves an up-to-date user block untouched', () => { + const node = makeStaleUserBlockNode('MYSTRUCT') + const flow = { rung: { nodes: [node] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(report.modified).toBe(false) + }) + + it('still reports an empty library pool when only user POUs resolve', () => { + const node = makeStaleUserBlockNode() + + const report = restampFlowLibraryVariants( + [{ rung: { nodes: [node] } }], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(report.poolEmpty).toBe(true) + expect(report.modified).toBe(true) + }) +}) + +describe('restampFlowLibraryVariants — malformed or oddly cased data', () => { + it('matches a library POU name case-insensitively', () => { + const node = makeStaleAdrNode() + node.data.variant.name = 'adr' + const flow = { rung: { nodes: [node] } } + + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) + + expect(kindOf(changes, 'type')).toBeDefined() + expect(node.data.variant.variables[0].type.value).toBe('__XWORD') + }) + + it('skips a block whose persisted variant has no variables array', () => { + const node = makeStaleAdrNode() + delete (node.data.variant as { variables?: unknown }).variables + const flow = { rung: { nodes: [node] } } + + expect(() => restampFlowLibraryVariants([flow], makeSystemLibraries(), [])).not.toThrow() + }) + + it('skips a pin node whose block variant has no variables array', () => { + const blockNode = makeStaleUserBlockNode() + delete (blockNode.data.variant as { variables?: unknown }).variables + const pinNode = makePinNode() + const flow = { rung: { nodes: [blockNode, pinNode] } } + + expect(() => + restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ), + ).not.toThrow() + expect(pinNode.data.block.variableType.type.value).toBe('OLDSTRUCT') + }) +}) + +describe('restampFlowLibraryVariants — ladder pin nodes', () => { + it("refreshes the pin node's cached type from the block it connects to", () => { + const pinNode = makePinNode() + const flow = { rung: { nodes: [makeStaleUserBlockNode(), pinNode] } } + + restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(pinNode.data.block.variableType.type).toEqual({ definition: 'user-data-type', value: 'MYSTRUCT' }) + }) + + it('leaves a pin node whose block is not in the rung alone', () => { + const pinNode = makePinNode() + const flow = { rung: { nodes: [pinNode] } } + + const report = restampFlowLibraryVariants( + [flow], + [], + [makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }])], + ) + + expect(report.modified).toBe(false) + expect(pinNode.data.block.variableType.type.value).toBe('OLDSTRUCT') + }) +}) + +describe('DOPE-548 — a user FB pin type change must not break a linked variable', () => { + it('leaves the link intact once the block and its pin node are re-stamped', () => { + const flow = { + name: 'Prog', + rungs: [{ id: 'r1', nodes: [makeStaleUserBlockNode(), makePinNode()], edges: [] }], + } + // The FB now declares IN1 : MyStruct, and the POU variable follows it. + const userPous = [ + makeUserPou('MyFB', [{ name: 'IN1', class: 'input', definition: 'user-data-type', value: 'MyStruct' }]), + ] + const variables = [ + { id: '1', name: 'motor', type: { definition: 'user-data-type', value: 'MYSTRUCT' } }, + ] as unknown as PLCVariable[] + + restampFlowLibraryVariants([flow], [], userPous) + + const updateNodes = vi.fn() + syncNodesWithVariables(variables, [flow] as unknown as Parameters[1], updateNodes) + + // Without the re-stamp the pin still reads OLDSTRUCT and the node is + // replaced by a broken-… payload flagged wrongVariable. + expect(updateNodes).not.toHaveBeenCalled() + }) +}) 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..52e77f8ad --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts @@ -0,0 +1,79 @@ +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 0c123abaf..3e7360606 100644 --- a/src/frontend/utils/PLC/array-codegen-helpers.ts +++ b/src/frontend/utils/PLC/array-codegen-helpers.ts @@ -1,38 +1,26 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' +import { IEC_BASE_TYPES, parseStringLength } from '../iec-types-registry' import { parseDimensionRange } from './dimension-range' -const BASE_TYPE_TO_IEC: Record = { - bool: 'IEC_BOOL', - sint: 'IEC_SINT', - int: 'IEC_INT', - dint: 'IEC_DINT', - lint: 'IEC_LINT', - usint: 'IEC_USINT', - uint: 'IEC_UINT', - udint: 'IEC_UDINT', - ulint: 'IEC_ULINT', - byte: 'IEC_BYTE', - word: 'IEC_WORD', - dword: 'IEC_DWORD', - lword: 'IEC_LWORD', - real: 'IEC_REAL', - lreal: 'IEC_LREAL', - string: 'IEC_STRING', - wstring: 'IEC_WSTRING', - - // Duration and calendar types. Absent until DOPE-584's type sweep: a C++ - // block declaring `TIME` emitted `strucpp::TIME`, which names nothing, and the - // build failed on generated code the user never wrote. The aliases these map - // to are the ones strucpp declares (`IEC_TIME = IECVar`, and so on). - time: 'IEC_TIME', - date: 'IEC_DATE', - tod: 'IEC_TOD', - dt: 'IEC_DT', - - // The long spellings IEC 61131-3 also allows for the same two types. - time_of_day: 'IEC_TOD', - date_and_time: 'IEC_DT', -} +/** + * IEC elementary type name (lower-cased) to the `IEC_*` alias STruC++ declares + * for it in `iec_var.hpp` — `IEC_BOOL`, `IEC_TIME`, and so on. + * + * Derived from the registry rather than restated, so a type added there is + * mapped here without a second edit. This used to be a hand-written list, which + * is how `TIME` came to emit `strucpp::TIME` — a name that does not exist — and + * failed the build on generated code the user never wrote. + * + * Aliases resolve to their canonical type's spelling (`time_of_day` → + * `IEC_TOD`). The leading underscores of `__XWORD` are dropped, because + * STruC++ spells that one `IEC_XWORD`. + */ +const BASE_TYPE_TO_IEC: Record = Object.fromEntries( + IEC_BASE_TYPES.flatMap((type) => { + const alias = `IEC_${type.name.replace(/^_+/, '')}` + return [type.name, ...type.aliases].map((spelling) => [spelling.toLowerCase(), alias]) + }), +) /** * Check if a PLCVariable has an array type definition. @@ -90,11 +78,56 @@ const getArrayBaseTypeValue = (variable: PLCVariable): string => { * This is the spelling for a variable that IS the type. An element INSIDE an * array is spelled differently — see `mapArrayElementTypeToIEC`. */ +/** + * 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}>` +} + /** * Spell a type the way strucpp spells it as an ARRAY ELEMENT, which is not * always how it spells the same type as a standalone variable. @@ -123,11 +156,19 @@ const mapUserTypeToIEC = (typeName: string, userTypeNames?: ReadonlySet) * type the name refers to, so the name alone decides. */ const mapArrayElementTypeToIEC = (baseType: string): string => { + const sized = sizedStringIECType(baseType) + if (sized) return sized const elementary = BASE_TYPE_TO_IEC[baseType.toLowerCase()] - return elementary ?? baseType.toUpperCase() + if (elementary) return elementary + // A generic and the descriptor it carries are one runtime type, and that + // spelling is the same in element position — the bare name is not a C++ type + // at all. Everything else user-defined is bare here; see the note above. + return GENERIC_TYPE_TO_IEC[baseType.toUpperCase()] ?? baseType.toUpperCase() } 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: a type the map does not know. It takes the scalar spelling @@ -201,6 +242,42 @@ const multiDimensionalContainerType = (variable: PLCVariable): string | null => 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 + + // Scalar spelling, not the element one: a view's parameter goes through + // strucpp's ordinary variable-type mapping, so an enumeration keeps its + // `IEC_` wrapper here even though it loses it inside `Array1D`. + 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: @@ -225,6 +302,9 @@ const multiDimensionalContainerType = (variable: PLCVariable): string | null => */ 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) if (multiDimensional) return ` strucpp::${multiDimensional} *${name};\n` @@ -239,6 +319,8 @@ export { getArrayTotalElements, getVariableIECType, isArrayVariable, + isDescriptorPinType, + isVariableLengthArray, mapArrayElementTypeToIEC, mapBaseTypeToIEC, mapUserTypeToIEC, 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/library-block-divergence.ts b/src/frontend/utils/PLC/library-block-divergence.ts new file mode 100644 index 000000000..2402518a9 --- /dev/null +++ b/src/frontend/utils/PLC/library-block-divergence.ts @@ -0,0 +1,85 @@ +/** + * Does a placed block still match the LIBRARY definition it came from? + * + * The graphical editors already ask this of a block backed by a POU in the + * project, and show an update badge when the answer is no. A block out of an + * installed library had no such check: the load-time re-stamp reports a pin the + * library added and deliberately does not apply it — growing a block needs the + * node's handles rebuilt, which project load will not do — so the console said + * "1 placed block does not draw it yet" and nothing could act on it. + * + * The version is not the question. A library rebuilt in place, which is what + * developing one looks like, changes the block without changing its version, + * and the update paths keyed on a version change never fire. + * + * Only the pin SET is compared. Everything else the re-stamp handles — a type, + * a documentation string, `extensible` — it applies on load without needing + * geometry, so it is already in the block by the time anything is drawn. + */ + +import type { BlockVariant } from '@root/middleware/shared/ports/block-types' +import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' + +/** EN/ENO are implicit control pins; a library POU never declares them. */ +const IMPLICIT_PINS = new Set(['EN', 'ENO', 'OUT']) + +const WIRED_CLASSES = ['input', 'output', 'inOut'] + +/** Drawn as a pin, and named, so it can be compared at all. */ +const isWired = (variable: { name?: string; class?: string }): boolean => + WIRED_CLASSES.includes(variable?.class ?? '') && typeof variable?.name === 'string' && variable.name.length > 0 + +/** Name and side, which is what a redraw depends on. */ +const pinKey = (variable: { name: string; class?: string }): string => + `${variable.name.toUpperCase()}|${variable.class ?? ''}` + +/** + * The library POU a variant was stamped from, or null when the project owns it. + * + * A project POU takes precedence: the project's own interface is the truth for + * those, and the editors have their own check for them. + */ +export function findLibraryPou( + variant: Pick, + systemLibraries: SystemLibrary[], + userPouNames: Iterable, +): SystemLibrary['pous'][number] | null { + // A node can carry a variant with no name -- a block dropped but not yet + // given one, and whatever a half-written project holds. Nameless is simply + // not a library block; the comparison this replaced tolerated it by never + // reaching for the name at all. + const wanted = upper(variant?.name) + if (wanted === null) return null + + for (const name of userPouNames) { + if (upper(name) === wanted) return null + } + + for (const library of systemLibraries) { + const match = (library?.pous ?? []).find((pou) => upper(pou?.name) === wanted) + if (match) return match + } + return null +} + +/** Upper-cased, or null when there is no name to compare. */ +function upper(name: string | undefined | null): string | null { + return typeof name === 'string' && name.length > 0 ? name.toUpperCase() : null +} + +/** + * True when the placed variant's pins no longer match the library's. + * + * Added, removed and side-changed pins all count: each one means the block on + * the canvas is drawing something other than what the library declares. + */ +export function libraryVariantDiverges(variant: BlockVariant, libraryPou: SystemLibrary['pous'][number]): boolean { + const wanted = new Set((libraryPou.variables ?? []).filter(isWired).map(pinKey)) + const drawn = new Set( + (variant.variables ?? []).filter((variable) => isWired(variable) && !IMPLICIT_PINS.has(variable.name)).map(pinKey), + ) + + if (wanted.size !== drawn.size) return true + for (const pin of wanted) if (!drawn.has(pin)) return true + return false +} diff --git a/src/frontend/utils/PLC/pou-signature-serializer.ts b/src/frontend/utils/PLC/pou-signature-serializer.ts index 4b4e130f2..2039d8c5d 100644 --- a/src/frontend/utils/PLC/pou-signature-serializer.ts +++ b/src/frontend/utils/PLC/pou-signature-serializer.ts @@ -90,6 +90,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 2a7cf3d05..aee1e4ef8 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 */ export 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, } } @@ -196,9 +200,14 @@ export const parseTextualPouFromString = (content: string, language: string, typ throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationMatch = remainingContent.match( - 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) { throw new Error(formatParseError(`Could not find ${typeKeyword} declaration`)) @@ -206,6 +215,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`)) @@ -238,6 +248,7 @@ export const parseTextualPouFromString = (content: string, language: string, typ pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -277,9 +288,14 @@ export const parseHybridPouFromString = (content: string, language: string, type throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationMatch = remainingContent.match( - 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) { throw new Error(formatParseError(`Could not find ${typeKeyword} declaration`)) @@ -287,6 +303,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`)) @@ -318,6 +335,7 @@ export const parseHybridPouFromString = (content: string, language: string, type pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -358,9 +376,14 @@ export const parseGraphicalPouFromString = (content: string, language: string, t throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationMatch = remainingContent.match( - 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) { throw new Error(formatParseError(`Could not find ${typeKeyword} declaration`)) @@ -368,6 +391,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`)) @@ -430,6 +454,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/project-libraries-json.ts b/src/frontend/utils/PLC/project-libraries-json.ts new file mode 100644 index 000000000..75910e71f --- /dev/null +++ b/src/frontend/utils/PLC/project-libraries-json.ts @@ -0,0 +1,51 @@ +/** + * Replace `data.libraries` in a `project.json` document, leaving every other + * key byte-for-byte as it was. + * + * The library manager changes one field of a file the whole editor writes, so a + * full re-serialise would rewrite unrelated keys — including any a newer build + * added that this one does not model. Surgical replacement keeps the diff to the + * line that changed. + * + * Pure and string-in/string-out so the GUI's save path and the CLI can share it + * rather than growing a second implementation each. + */ + +export interface ProjectLibraryRef { + name: string + version: string +} + +export type WithProjectLibrariesResult = { ok: true; json: string } | { ok: false; error: string } + +/** + * Refs are sorted by name so two editors repinning the same project produce the + * same file, and a diff shows the version change rather than a reordering. + */ +export function sortProjectLibraryRefs(refs: readonly ProjectLibraryRef[]): ProjectLibraryRef[] { + return [...refs].sort((a, b) => a.name.localeCompare(b.name)).map((ref) => ({ name: ref.name, version: ref.version })) +} + +export function withProjectLibraries(json: string, refs: readonly ProjectLibraryRef[]): WithProjectLibrariesResult { + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + return { ok: false, error: 'project.json on disk is malformed' } + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { ok: false, error: 'project.json on disk is not an object' } + } + + const document = parsed as Record + // A project.json without a `data` key is not one this editor wrote, but the + // caller asked for libraries to be set — create it rather than failing. + const data = + typeof document.data === 'object' && document.data !== null && !Array.isArray(document.data) + ? (document.data as Record) + : {} + document.data = data + data.libraries = sortProjectLibraryRefs(refs) + + return { ok: true, json: JSON.stringify(document, null, 2) } +} diff --git a/src/frontend/utils/PLC/restamp-block-variants.ts b/src/frontend/utils/PLC/restamp-block-variants.ts deleted file mode 100644 index 18fe4d5d5..000000000 --- a/src/frontend/utils/PLC/restamp-block-variants.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { BlockVariant } from '@root/middleware/shared/ports/block-types' -import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' -import type { PLCPou } from '@root/middleware/shared/ports/types' - -/** - * Refresh the *types* carried by placed graphical block variants from whatever - * currently defines the block. - * - * A block's signature is copied into `node.data.variant` once, when the block - * is dropped on the canvas (see the FBD/LD `handleAddElementByDropping`), and - * then frozen in the saved project. When the definition later changes its pin - * or return type, already-placed blocks keep the stale one: the transpiler - * (which reads `node.data.variant` via `collect-library-blocks`) emits the old - * type, and the relink pass compares a refreshed variable against the stale pin - * and breaks a link that is semantically fine (DOPE-548). - * - * Two definitions feed a placed block, and both are re-stamped on project load: - * the bundled system libraries, and the project's own functions and function - * blocks. A user POU takes precedence over a library entry of the same name, - * since the project owns its own interface. - * - * Ladder pin nodes cache their pin's type a second time, in - * `data.block.variableType`, and that copy is the one the relink pass actually - * reads. It is otherwise refreshed only by a layout pass that does not run on - * load, so it is re-stamped here too. - * - * The refresh is intentionally type-only: it matches variables by name and - * copies the type, leaving the pin set, ids, handles and wiring untouched. That - * keeps extensible (variadic) blocks and existing connections intact while - * still propagating type changes. A signature that gained or lost a pin needs - * the node rebuilt, which stays the divergence badge's job. - */ - -type VariantVariable = BlockVariant['variables'][number] -type VariantVariableType = VariantVariable['type'] - -/** Pin types of one definition, keyed by variable name. IEC names are case-insensitive. */ -type PinTypes = Map - -/** Index every library POU by name, for O(1) lookup. IEC names are case-insensitive. */ -function indexLibraryPous(systemLibraries: SystemLibrary[]): Map { - const byName = new Map() - for (const library of systemLibraries) { - for (const pou of library.pous) { - // First definition wins; bundled libraries don't collide on name. - const key = pou.name.toUpperCase() - if (!byName.has(key)) byName.set(key, pou) - } - } - return byName -} - -/** Index the project's own functions and function blocks by name. */ -function indexUserPous(userPous: PLCPou[]): Map { - const byName = new Map() - for (const pou of userPous) byName.set(pou.name.toUpperCase(), pou) - return byName -} - -function libraryPinTypes(pou: SystemLibrary['pous'][number]): PinTypes { - const types: PinTypes = new Map() - for (const variable of pou.variables) types.set(variable.name.toUpperCase(), variable.type as VariantVariableType) - return types -} - -function userPouPinTypes(pou: PLCPou): PinTypes { - const types: PinTypes = new Map() - for (const variable of pou.interface?.variables ?? []) { - // Placed variants upper-case their values (see the drop path), and a user - // pin may be a data type or an array, which the variant schema's union - // does not name — the placed shape has always carried them. - types.set(variable.name.toUpperCase(), { - definition: variable.type.definition, - value: variable.type.value.toUpperCase(), - } as VariantVariableType) - } - const returnType = pou.interface?.returnType - // A function's return pin is synthesised as OUT when the block is dropped. - if (pou.pouType === 'function' && returnType) { - types.set('OUT', { definition: 'base-type', value: returnType.toUpperCase() } as VariantVariableType) - } - return types -} - -/** A minimal block-bearing node shape; both FBD and LD nodes satisfy it. */ -type BlockBearingNode = { type?: string; data?: { variant?: BlockVariant } } - -/** A ladder pin node: it caches the type of the block pin it connects to. */ -type PinBearingNode = { - type?: string - data?: { - block?: { id?: string; handleId?: string; variableType?: VariantVariable } - } -} - -function restampBlockNodes( - nodes: BlockBearingNode[], - libraryPousByName: Map, - userPousByName: Map, -): number { - let changed = 0 - for (const node of nodes) { - if (node?.type !== 'block') continue - const variant = node.data?.variant - const name = variant?.name - // `node.data` is `z.any()` in the flow schema, so a hand-edited or - // half-migrated project can reach here without a usable variant. - if (!variant || !name || !Array.isArray(variant.variables)) continue - - // The project owns its own POUs, so they win over a library of the same name. - const userPou = userPousByName.get(name.toUpperCase()) - const libPou = userPou ? undefined : libraryPousByName.get(name.toUpperCase()) - if (!userPou && !libPou) continue - const pinTypes = userPou ? userPouPinTypes(userPou) : libraryPinTypes(libPou!) - - for (const variable of variant.variables) { - const next = pinTypes.get(variable.name.toUpperCase()) - // A pin the definition no longer declares (EN/ENO, a removed one) is left - // alone: dropping it would orphan its handle and its wiring. - if (!next) continue - const current = variable.type - if (current.definition === next.definition && current.value === next.value) continue - variable.type = { definition: next.definition, value: next.value } as VariantVariableType - changed += 1 - } - } - return changed -} - -/** Re-stamp the pin type each ladder pin node caches from its block's signature. */ -function restampPinNodes(nodes: Array): number { - const variantsByBlockId = new Map() - for (const node of nodes) { - const variant = node?.type === 'block' ? node.data?.variant : undefined - const id = (node as { id?: string }).id - if (variant && Array.isArray(variant.variables) && typeof id === 'string') variantsByBlockId.set(id, variant) - } - if (variantsByBlockId.size === 0) return 0 - - let changed = 0 - for (const node of nodes) { - if (node?.type !== 'variable') continue - const block = node.data?.block - if (!block?.id || !block.handleId) continue - const variant = variantsByBlockId.get(block.id) - if (!variant) continue - - const handleId = block.handleId.toUpperCase() - const pin = variant.variables.find((variable) => variable.name.toUpperCase() === handleId) - if (!pin) continue - - const current = block.variableType?.type - if (current?.definition === pin.type.definition && current?.value === pin.type.value) continue - block.variableType = { name: pin.name, class: pin.class, type: { ...pin.type } } - changed += 1 - } - return changed -} - -/** - * Re-stamp every block in the given flows from the current system libraries and - * the project's own POUs. Mutates in place; call it on a clone of loaded data. - * - * @returns how many types were refreshed, for the load-time console note. - */ -export function restampFlowBlockVariants( - flows: Array<{ rung?: { nodes?: unknown }; rungs?: Array<{ nodes?: unknown }> }>, - systemLibraries: SystemLibrary[], - userPous: PLCPou[], -): number { - const libraryPousByName = indexLibraryPous(systemLibraries) - const userPousByName = indexUserPous(userPous) - if (libraryPousByName.size === 0 && userPousByName.size === 0) return 0 - - let changed = 0 - for (const flow of flows) { - const rungs = flow.rungs ?? (flow.rung ? [flow.rung] : []) - for (const rung of rungs) { - const nodes = rung?.nodes - if (!Array.isArray(nodes)) continue - changed += restampBlockNodes(nodes as BlockBearingNode[], libraryPousByName, userPousByName) - // After the blocks, so the pins copy the refreshed types. - changed += restampPinNodes(nodes as Array) - } - } - return changed -} diff --git a/src/frontend/utils/PLC/restamp-library-variants.ts b/src/frontend/utils/PLC/restamp-library-variants.ts new file mode 100644 index 000000000..6b6332130 --- /dev/null +++ b/src/frontend/utils/PLC/restamp-library-variants.ts @@ -0,0 +1,576 @@ +import type { BlockVariant } from '@root/middleware/shared/ports/block-types' +import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' +import type { PLCPou } from '@root/middleware/shared/ports/types' + +import { blockParameterSide } from '../graphical/in-out-pin-rules' + +/** + * Refresh placed graphical block variants from the current library definitions. + * + * A block's signature is copied into `node.data.variant` when the block is + * dropped on the canvas and then frozen in the saved project. Two definitions + * can back a placed block, and both are re-stamped: the installed libraries and + * the project's own functions and function blocks. A user POU wins over a + * library entry of the same name, since the project owns its own interface. + * + * The two are treated differently, because the reporting only makes sense for + * one of them. A library changes under the project, so every difference is + * described and the breaking ones are left for the user to fix. A user POU + * changes *because* the user just edited it, so it gets a silent, type-only + * refresh (matching by pin name, never touching the pin set) -- there is + * nothing to tell them that they do not already know, and the stale copy is + * what breaks the relink pass (DOPE-548). + * + * What is applied in place for a library block: pin `type`, pin `class` that + * stays on the same side, `documentation`, `extensible` and the block `type`. + * None of those move a pin. + * + * A pin the library ADDED is applied only when the caller supplies + * `measureBlock` — the canvas wires to `data.handles`, so a new pin needs the + * node's geometry recomputed, and that is each language's own `getBlockSize`. + * Project load does not supply it: growing every placed block would resize and + * relayout diagrams before the user has seen them. It is supplied by the + * explicit library-update action instead, which also runs where components may + * be imported. + * + * What is reported and NOT applied: a pin removed, or a `class` change that + * moves a pin to the other side. Both invalidate existing wiring, so the + * library definition is reported and the diagram is left for the user to fix. + */ + +type LibraryPou = SystemLibrary['pous'][number] +type VariantVariable = BlockVariant['variables'][number] + +/** EN/ENO are implicit control pins; a library POU never declares them. */ +const IMPLICIT_PINS = new Set(['EN', 'ENO']) + +export type RestampSeverity = 'info' | 'warning' | 'error' + +export type RestampChangeKind = + | 'type' + | 'class' + | 'documentation' + | 'extensible' + | 'block-type' + | 'pin-added' + | 'pin-removed' + | 'pin-side' + +export interface RestampChange { + /** Library block name, e.g. `ANALOG_IN`. */ + block: string + /** POU the block sits in, when the caller supplied one. */ + pou?: string + /** Instance name of the placed block, e.g. `ANALOG_IN0`. */ + instance?: string + pin?: string + kind: RestampChangeKind + from?: string + to?: string + severity: RestampSeverity + /** False when the change was detected but needs geometry this cannot rebuild. */ + applied: boolean + /** Something is wired to the pin. */ + connected?: boolean +} + +/** Geometry for a block, as each language's `getBlockSize` returns it. */ +export interface BlockGeometry { + handles: unknown[] + leftHandles: unknown[] + rightHandles: unknown[] + width: number + height: number +} + +/** A language's `getBlockSize`, injected so this stays language-agnostic. */ +export type MeasureBlock = (variant: BlockVariant, handlePosition: { x: number; y: number }) => BlockGeometry + +export interface RestampOptions { + /** POU the flows belong to, stamped onto every change for reporting. */ + pou?: string + /** Supply to let an added pin be applied rather than only reported. */ + measureBlock?: MeasureBlock +} + +export interface RestampReport { + changes: RestampChange[] + /** No libraries were loaded, so nothing could be checked. */ + poolEmpty: boolean + /** + * The flow was actually altered. Callers persist on this rather than on + * `changes`: some corrections are silent -- refreshing the copy of a pin + * held by the variable wired to it produces no message of its own, and + * without this the fix would be redone on every load and never saved. + */ + modified: boolean +} + +/** Index every library POU by name. First definition wins. IEC names are case-insensitive. */ +function indexLibraryPous(systemLibraries: SystemLibrary[]): Map { + const byName = new Map() + for (const library of systemLibraries) { + for (const pou of library.pous) { + const key = pou.name.toUpperCase() + if (!byName.has(key)) byName.set(key, pou) + } + } + return byName +} + +/** Index the project's own functions and function blocks by name. */ +function indexUserPous(userPous: PLCPou[]): Map { + const byName = new Map() + for (const pou of userPous) byName.set(pou.name.toUpperCase(), pou) + return byName +} + +/** The pin types a user POU's interface declares, keyed by upper-cased pin name. */ +function userPouPinTypes(pou: PLCPou): Map { + const types = new Map() + for (const variable of pou.interface?.variables ?? []) { + // Placed variants upper-case their values (see the drop path), and a user + // pin may be a data type or an array, which the variant schema's union + // does not name -- the placed shape has always carried them. + types.set(variable.name.toUpperCase(), { + definition: variable.type.definition, + value: variable.type.value.toUpperCase(), + } as VariantVariable['type']) + } + const returnType = pou.interface?.returnType + // A function's return pin is synthesised as OUT when the block is dropped. + if (pou.pouType === 'function' && returnType) { + types.set('OUT', { definition: 'base-type', value: returnType.toUpperCase() } as VariantVariable['type']) + } + return types +} + +type FlowEdge = { + source?: string + target?: string + sourceHandle?: string | null + targetHandle?: string | null +} + +type Handle = { glbPosition?: { x: number; y: number } } + +/** + * A variable wired to a block pin keeps its own copy of that pin's signature. + * The canvas renders its type as the `(*TYPE*)` placeholder and validates + * dropped variables against it, so it has to move with the pin. + */ +type AttachedPin = { + id?: string + handleId?: string + variableType?: { name?: string; class?: string; type?: { definition: string; value: string } } +} + +type BlockBearingNode = { + id?: string + type?: string + width?: number + height?: number + measured?: { width: number; height: number } + data?: { + variant?: BlockVariant + variable?: { name?: string } + connectedVariables?: Array<{ handleId?: string }> + block?: AttachedPin + handles?: unknown[] + inputHandles?: unknown[] + outputHandles?: unknown[] + inputConnector?: Handle + outputConnector?: Handle + } +} + +type RungLike = { nodes?: unknown; edges?: unknown } + +/** Is anything wired to this pin — an edge, or a variable bound to the handle? */ +function isPinConnected(node: BlockBearingNode, pin: string, edges: FlowEdge[]): boolean { + if (node.id) { + for (const edge of edges) { + if (edge.source === node.id && edge.sourceHandle === pin) return true + if (edge.target === node.id && edge.targetHandle === pin) return true + } + } + return (node.data?.connectedVariables ?? []).some((bound) => bound.handleId === pin) +} + +const typeLabel = (type: { definition: string; value: string }): string => type.value + +/** + * Recompute a node's handles and box for its current variant, anchored on the + * input connector it already has so the block does not move. + */ +function remeasure(node: BlockBearingNode, measureBlock: MeasureBlock): boolean { + const variant = node.data?.variant + const anchor = node.data?.inputConnector?.glbPosition + if (!variant || !node.data || !anchor) return false + + const size = measureBlock(variant, { x: anchor.x, y: anchor.y }) + node.data.handles = size.handles + node.data.inputHandles = size.leftHandles + node.data.outputHandles = size.rightHandles + node.data.inputConnector = size.leftHandles[0] as Handle + node.data.outputConnector = size.rightHandles[0] as Handle + node.width = size.width + node.height = size.height + node.measured = { width: size.width, height: size.height } + return true +} + +/** + * Type-only refresh of a block backed by one of the project's own POUs. + * + * Matches pins by name and copies the type across; the pin set, ids, handles + * and wiring are left exactly as they are. A pin the interface no longer + * declares (EN/ENO, one the user removed) is skipped rather than dropped, + * because dropping it would orphan its handle and its wiring, and a pin the + * interface gained is not added, because that needs the node rebuilt. Both + * stay the divergence badge's job. + */ +function restampUserPouBlock(variant: BlockVariant, pou: PLCPou): boolean { + const pinTypes = userPouPinTypes(pou) + let modified = false + for (const variable of variant.variables) { + const next = pinTypes.get(variable.name.toUpperCase()) + if (!next) continue + const current = variable.type + if (current.definition === next.definition && current.value === next.value) continue + variable.type = { definition: next.definition, value: next.value } as VariantVariable['type'] + modified = true + } + return modified +} + +function restampNodes( + nodes: BlockBearingNode[], + edges: FlowEdge[], + libraryPousByName: Map, + userPousByName: Map, + pou: string | undefined, + measureBlock: MeasureBlock | undefined, + changes: RestampChange[], +): boolean { + let modified = false + + for (const node of nodes) { + if (node?.type !== 'block') continue + const variant = node.data?.variant + const name = variant?.name + // `node.data` is `z.any()` in the flow schema, so a hand-edited or + // half-migrated project can reach here without a usable variant. + if (!variant || !name || !Array.isArray(variant.variables)) continue + + // The project owns its own POUs, so they win over a library of the same + // name -- and they are refreshed silently, without the library reporting. + const userPou = userPousByName.get(name.toUpperCase()) + if (userPou) { + if (restampUserPouBlock(variant, userPou)) modified = true + continue + } + + const libPou = libraryPousByName.get(name.toUpperCase()) + if (!libPou) continue + + const instance = node.data?.variable?.name + const record = (change: Omit): void => { + changes.push({ block: name, pou, instance, ...change }) + } + + if (variant.documentation !== libPou.documentation) { + variant.documentation = libPou.documentation + modified = true + record({ kind: 'documentation', severity: 'info', applied: true }) + } + + if ((variant.extensible ?? false) !== (libPou.extensible ?? false)) { + const from = String(variant.extensible ?? false) + variant.extensible = libPou.extensible ?? false + modified = true + record({ kind: 'extensible', from, to: String(variant.extensible), severity: 'warning', applied: true }) + } + + // 'generic' is the unnamed placeholder block, which resolves to no library. + // + // Reported, never applied: a function block needs an instance variable in + // the POU and a function must not have one, so switching the kind under a + // placed block leaves it referring to a variable that does not exist -- the + // editor then marks it wrong with nothing to explain why. + if (variant.type !== 'generic' && variant.type !== libPou.type) { + record({ kind: 'block-type', from: variant.type, to: libPou.type, severity: 'error', applied: false }) + } + + const libVarByName = new Map(libPou.variables.map((variable) => [variable.name.toUpperCase(), variable])) + const seen = new Set() + // Block width and handle positions are measured from the pins: their + // names, which side they sit on, and whether an in-out marker has to be + // paid for. Anything that moves those needs the box measured again. + let geometryChanged = false + + for (const variable of variant.variables) { + if (IMPLICIT_PINS.has(variable.name.toUpperCase())) continue + seen.add(variable.name.toUpperCase()) + const connected = isPinConnected(node, variable.name, edges) + const libVar = libVarByName.get(variable.name.toUpperCase()) + + if (!libVar) { + // An extensible block grows past its declared parameters -- ADD's IN3, + // IN4 and so on are the diagram's, not the library's. Only a fixed + // block can actually lose a pin. + if (libPou.extensible) continue + record({ + kind: 'pin-removed', + pin: variable.name, + from: typeLabel(variable.type), + severity: connected ? 'error' : 'warning', + applied: false, + connected, + }) + continue + } + + if (variable.class !== libVar.class) { + const from = variable.class + if (blockParameterSide(variable) === blockParameterSide(libVar)) { + variable.class = libVar.class + geometryChanged = true + modified = true + record({ kind: 'class', pin: variable.name, from, to: libVar.class, severity: 'info', applied: true }) + } else { + record({ + kind: 'pin-side', + pin: variable.name, + from, + to: libVar.class, + severity: connected ? 'error' : 'warning', + applied: false, + connected, + }) + } + } + + const next = libVar.type + const current = variable.type + if (current.definition !== next.definition || current.value !== next.value) { + const from = typeLabel(current) + // A library types `generic-type` value as a plain string; the variant + // narrows it to the ANY_* union, so the pairing has to be asserted. + variable.type = { definition: next.definition, value: next.value } as VariantVariable['type'] + modified = true + record({ + kind: 'type', + pin: variable.name, + from, + to: next.value, + severity: connected ? 'warning' : 'info', + applied: true, + connected, + }) + } + } + + const added = libPou.variables.filter( + (libVar) => !IMPLICIT_PINS.has(libVar.name.toUpperCase()) && !seen.has(libVar.name.toUpperCase()), + ) + // Appended together so the box is measured once, whatever the library added. + let grown = false + if (added.length > 0 && measureBlock) { + const before = variant.variables.length + variant.variables.push(...(added as VariantVariable[])) + grown = remeasure(node, measureBlock) + if (!grown) variant.variables.length = before + else { + geometryChanged = false // remeasure() already ran for this node + modified = true + } + } + if (geometryChanged && measureBlock) remeasure(node, measureBlock) + + for (const libVar of added) { + record({ + kind: 'pin-added', + pin: libVar.name, + to: typeLabel(libVar.type), + severity: grown ? 'info' : 'warning', + applied: grown, + }) + } + } + + return modified +} + +/** + * Re-stamp the copy of a block pin's signature that the variable wired to it + * carries. + * + * A variable node caches the pin it connects to in `data.block.variableType`, + * and that copy -- not the block's own variant -- is what the canvas renders as + * the `(*TYPE*)` placeholder, what it validates a dropped variable against, and + * what the relink pass reads. Leaving it behind shows the old type and rejects + * a variable of the new one, which reads as the update having done nothing. + * + * Driven off the variants rather than off the definitions, so it runs AFTER the + * blocks are re-stamped and covers library and user-POU blocks alike. + */ +function restampPinNodes(nodes: BlockBearingNode[]): boolean { + const variantsByBlockId = new Map() + for (const node of nodes) { + const variant = node?.type === 'block' ? node.data?.variant : undefined + if (variant && Array.isArray(variant.variables) && node.id) variantsByBlockId.set(node.id, variant) + } + if (variantsByBlockId.size === 0) return false + + let modified = false + for (const node of nodes) { + const attached = node.data?.block + if (!attached?.id || !attached.handleId) continue + const variant = variantsByBlockId.get(attached.id) + if (!variant) continue + + const handleId = attached.handleId.toUpperCase() + const pin = variant.variables.find((variable) => variable.name.toUpperCase() === handleId) + if (!pin) continue + + const current = attached.variableType + if ( + current?.name === pin.name && + current.class === pin.class && + current.type?.definition === pin.type.definition && + current.type?.value === pin.type.value + ) { + continue + } + attached.variableType = { name: pin.name, class: pin.class, type: { ...pin.type } } + modified = true + } + return modified +} + +/** + * Re-stamp every block in the given flows from the current system libraries and + * the project's own POUs. Mutates the flow objects in place. + * + * `flows` accepts both FBD flows (single `rung`) and LD flows (`rungs[]`); the + * shape is duck-typed so the helper stays language-agnostic. + */ +export function restampFlowLibraryVariants( + flows: Array<{ rung?: RungLike; rungs?: RungLike[] }>, + systemLibraries: SystemLibrary[], + userPous: PLCPou[], + options: RestampOptions = {}, +): RestampReport { + const libraryPousByName = indexLibraryPous(systemLibraries) + const userPousByName = indexUserPous(userPous) + // Nothing to check anything against. `poolEmpty` stays the library's story: + // a project whose own POUs still resolve is not an unchecked project. + const poolEmpty = libraryPousByName.size === 0 + if (poolEmpty && userPousByName.size === 0) return { changes: [], poolEmpty: true, modified: false } + + const changes: RestampChange[] = [] + let modified = false + for (const flow of flows) { + const rungs = flow.rungs ?? (flow.rung ? [flow.rung] : []) + for (const rung of rungs) { + const nodes = rung?.nodes + if (!Array.isArray(nodes)) continue + const edges = Array.isArray(rung?.edges) ? (rung.edges as FlowEdge[]) : [] + const touched = restampNodes( + nodes as BlockBearingNode[], + edges, + libraryPousByName, + userPousByName, + options.pou, + options.measureBlock, + changes, + ) + // After the blocks, so the pin nodes copy the refreshed types. + const pinsTouched = restampPinNodes(nodes as BlockBearingNode[]) + modified = modified || touched || pinsTouched + } + } + return { changes, poolEmpty, modified } +} + +export interface RestampSummaryLine { + severity: RestampSeverity + message: string +} + +const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}` + +/** Verb agreeing with a count of placed blocks. */ +const verb = (count: number, singular: string, pluralForm: string): string => (count === 1 ? singular : pluralForm) + +const where = (pous: Set): string => { + const named = [...pous].filter(Boolean).sort() + return named.length ? ` in ${named.join(', ')}` : '' +} + +function lineFor(change: RestampChange, count: number, pous: Set): string { + const { block, pin, from, to } = change + const at = where(pous) + switch (change.kind) { + case 'documentation': + return `${block}: documentation refreshed from the library — ${plural(count, 'block')}${at}.` + case 'type': + return `${block}.${pin}: type ${from} → ${to} — ${plural(count, 'block')}${at}.` + case 'class': + return `${block}.${pin}: class ${from} → ${to} — ${plural(count, 'block')}${at}.` + case 'extensible': + return `${block}: extensible ${from} → ${to} — ${plural(count, 'block')}${at}.` + case 'block-type': + return ( + `${block}: the library changed this from a ${from} to a ${to}. ` + + `${plural(count, 'placed block')}${at} still ${verb(count, 'carries', 'carry')} the old kind; ` + + `delete and re-place ${verb(count, 'it', 'them')}.` + ) + case 'pin-added': + // Applied and not-applied read the same until this is said out loud, and + // a block that just grew the pin still reporting "does not draw it yet" + // is how a working update gets mistaken for a broken one. + return change.applied + ? `${block}: the library added pin ${pin} (${to}). ${plural(count, 'placed block')}${at} ` + + `now ${verb(count, 'draws', 'draw')} it.` + : `${block}: the library added pin ${pin} (${to}). ${plural(count, 'placed block')}${at} ` + + `${verb(count, 'does not draw', 'do not draw')} it yet — hover the block and click its update badge.` + + case 'pin-removed': + return ( + `${block}: the library removed pin ${pin}. ${plural(count, 'placed block')}${at} still ` + + `${verb(count, 'draws', 'draw')} it` + + `${change.connected ? ', with something wired to it' : ''}.` + ) + case 'pin-side': + return ( + `${block}.${pin}: class ${from} → ${to} moves the pin to the other side. ` + + `${plural(count, 'placed block')}${at} ${verb(count, 'keeps', 'keep')} it where it is` + + `${change.connected ? ', with something wired to it' : ''}.` + ) + } +} + +/** + * Collapse per-block changes into one line each, so a project that places the + * same block a hundred times reports once rather than a hundred times. + */ +export function summariseRestampChanges(changes: RestampChange[]): RestampSummaryLine[] { + const groups = new Map }>() + for (const change of changes) { + const key = [change.block, change.kind, change.pin, change.from, change.to, change.severity].join('|') + const group = groups.get(key) + if (group) { + group.count += 1 + if (change.pou) group.pous.add(change.pou) + } else { + groups.set(key, { change, count: 1, pous: new Set(change.pou ? [change.pou] : []) }) + } + } + + const order: Record = { error: 0, warning: 1, info: 2 } + return [...groups.values()] + .map(({ change, count, pous }) => ({ severity: change.severity, message: lineFor(change, count, pous) })) + .sort((a, b) => order[a.severity] - order[b.severity] || a.message.localeCompare(b.message)) +} 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 0526b5bb5..e5add17bb 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 @@ -116,6 +116,49 @@ describe('buildTypeContext — the classification the compiler cannot make', () it('works with no project context at all', () => { expect(typeOf('x : TON;')).toEqual({ definition: 'user-data-type', value: 'TON' }) }) + + // ---- 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. The scanner rejects `ARRAY []` outright + // rather than letting it through as a named type, so the guarantee this + // test exists for still holds: an empty bound is never read as `ARRAY [*]`. + expect(() => parseIecStringToVariables('VAR\n bad : ARRAY [] OF INT;\nEND_VAR')).toThrow(/ARRAY dimension/) + }) + + 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') + }) }) describe('findDuplicateVariableName', () => { diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 329453b4b..c90985e5e 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,87 @@ 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..e0607490d 100644 --- a/src/frontend/utils/cpp/generateSTCode.ts +++ b/src/frontend/utils/cpp/generateSTCode.ts @@ -1,10 +1,18 @@ 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 = { pouName: string allVariables: PLCVariable[] + /** Names of the function blocks in scope, upper-cased. A pin typed by one is + * an alias for the caller's instance and the field already holds a pointer. */ + functionBlockNames?: ReadonlySet } /** @@ -28,17 +36,44 @@ 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. + */ +/** + * The member's name in the generated class. + * + * A member whose name matches its own type is mangled with a trailing + * underscore, because `&NODE` would otherwise name the type rather than the + * member. The struct field keeps the plain name; only the address taken here + * follows the class. */ -const generateVariableAssignment = (variable: PLCVariable): string => { +const memberName = (variable: PLCVariable): string => { const name = variable.name.toUpperCase() - if (multiDimensionalContainerType(variable)) { - return `vars.${name} = &${name};\n` + const typeName = variable.type.value?.toUpperCase() + return typeName === name ? `${name}_` : name +} + +const generateVariableAssignment = (variable: PLCVariable, functionBlockNames?: ReadonlySet): string => { + const name = variable.name.toUpperCase() + const member = memberName(variable) + // A function block passed as an in-out is aliased rather than copied, so the + // member is already a pointer at the caller's instance. + if (functionBlockNames?.has((variable.type.value ?? '').toUpperCase())) { + return `vars.${name} = ${member};\n` + } + if (multiDimensionalContainerType(variable) || isVariableLengthArray(variable)) { + return `vars.${name} = &${member};\n` } if (isArrayVariable(variable)) { const startIndex = getArrayStartIndex(variable) - return `vars.${name} = &${name}[${startIndex}] - ${startIndex};\n` + return `vars.${name} = &${member}[${startIndex}] - ${startIndex};\n` } - return `vars.${name} = &${name};\n` + return `vars.${name} = &${member};\n` } /** @@ -94,7 +129,7 @@ const wrapInGlobalLocks = (externals: PLCVariable[], call: string): string => { } const generateSTCode = (params: STCodeGenerationParams): string => { - const { pouName, allVariables } = params + const { pouName, allVariables, functionBlockNames } = params const structName = `${pouName.toUpperCase()}_VARS` const setupFunctionName = `${pouName.toLowerCase()}_setup` @@ -102,7 +137,7 @@ const generateSTCode = (params: STCodeGenerationParams): string => { let variableAssignments = '' for (const variable of cBlockInterfaceVariables(allVariables)) { - variableAssignments += generateVariableAssignment(variable) + variableAssignments += generateVariableAssignment(variable, functionBlockNames) } // Externals are filled inside the lock wrapper instead, immediately before 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 e4d342582..5dccb341a 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -1,6 +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 { parseStringLength } from './iec-types-registry' import type { TypeContext } from './PLC/variable-declarations' import { parseVariableDeclarations } from './PLC/variable-declarations' @@ -36,14 +37,46 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type * Returns null if not an array type, otherwise returns the parsed array type definition. * Also consumed by the global-variable-list text parser. */ +/** + * A `STRING(...)` / `WSTRING[...]` declaration, whatever sits between the + * delimiters. Matching the shape commits the writer to a length, so anything + * `parseStringLength` will not accept from here — `STRING[]`, `STRING(abc)`, + * `STRING(0)`, `STRING(999)`, the mismatched `STRING(23]` — is a mistake to + * report rather than a type name to keep. + */ +const SIZED_STRING_SHAPE = /^(W?STRING)\s*[([]\s*([^)\]]*?)\s*[)\]]$/i + +/** + * The declaration's type name and the length it got wrong, or `null` when the + * type is not sized-string-shaped or its length is one we can carry. + */ +const badStringLength = (typeStr: string): { typeName: string; got: string } | null => { + const shape = SIZED_STRING_SHAPE.exec(typeStr) + if (!shape) return null + const { length, valid } = parseStringLength(typeStr) + // `parseStringLength` reports `valid: true` with no length for an + // unqualified name, so the undefined case must be caught explicitly. + if (length !== undefined && valid) return null + return { typeName: shape[1].toUpperCase(), got: shape[2] } +} + 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] const baseTypeStr = arrayMatch[2].trim() + // An element's length is held to the same rule as a scalar's. Without this + // the element fails `baseTypeSchema` and is kept as a user data type named + // `STRING(0)`, which is then persisted and emitted verbatim into generated + // ST. Refusing the array here lets the caller report it as the syntax error + // it is. + if (badStringLength(baseTypeStr)) return null + // Parse dimensions (can be comma-separated for multi-dimensional arrays) const dimensionParts = dimensionsStr.split(',').map((d) => d.trim()) diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index 30574982d..8b05dd038 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -92,6 +92,46 @@ 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() + // The two delimiters are alternatives, not a character class: `STRING(23]` + // is a typo, and matching it would normalise it into a valid declaration. + const match = /^([A-Za-z_]\w*)\s*(?:\(\s*(\d+)\s*\)|\[\s*(\d+)\s*\])$/.exec(trimmed) + if (!match) return { base: trimmed.toUpperCase(), valid: true } + + const base = match[1].toUpperCase() + const length = Number(match[2] ?? match[3]) + 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 +140,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 +156,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 e19e5a8a4..8b2ae7a76 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -238,6 +238,21 @@ 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, _description, _url, isMainFrame) => { + // A subframe that fails is its own business; reloading over it would + // throw away the renderer that is already up. + if (!isMainFrame) return + 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/menu.ts b/src/main/menu.ts index afc61af9f..ac873d748 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -117,9 +117,21 @@ export default class MenuBuilder { return response } - async handleOpenProjectByPath(projectPath: string) { - const response = await this.projectService.openProjectByPath(projectPath) - this.mainWindow.webContents.send('project:open-recent-accelerator', response) + /** + * Open Recent — hand the renderer the PATH and let it open the project the + * same way every other entry point does. + * + * This used to read the project here and send the service response across, + * which crashed the renderer twice over: the response is an envelope + * (`{ success, data }`) and was passed on as though it were the payload, and + * the payload it wraps is raw file content that has never been through + * `parseProjectFiles`. The store action then set `meta` and `data` from two + * fields that do not exist on it, and the first component to read + * `project.data` threw. A failed open took the same route, so a project that + * had been moved or deleted crashed instead of raising a toast. + */ + handleOpenProjectByPath(projectPath: string) { + this.mainWindow.webContents.send('project:open-recent-accelerator', projectPath) } handleCloseTab() { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 249c828b7..4f7be388b 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -147,6 +147,11 @@ import { PackageManagerModule } from '../../../backend/editor/package-manager' import { BootloaderApiClient } from '../../../backend/editor/runtime/bootloader-api-client' 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, getPdfExportSavePath, @@ -160,6 +165,7 @@ import { interpretPluginCommandResponse, type PluginCommandOutcome, } from '../../../backend/shared/utils/vpp/screen-actions' +import type { EnabledArchives, LibraryRef } from '../../../middleware/shared/ports/library-types' import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' const EditSessionClientSchema = z.object({ @@ -1059,6 +1065,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 ===================== @@ -1320,9 +1331,11 @@ class MainProcessBridge implements MainIpcModule { const result = await dialog.showOpenDialog(this.mainWindow, { title: 'Install Library', filters: [ - { name: 'Library files', extensions: ['stlib', 'lib', 'library'] }, + { name: 'Library files', extensions: ['stlib', 'lib', 'library', 'zip'] }, { name: 'STruC++ archive', extensions: ['stlib'] }, { name: 'CODESYS library', extensions: ['lib', 'library'] }, + // A ZIP of library files: every one it holds is installed. + { name: 'Library bundle', extensions: ['zip'] }, ], properties: ['openFile'], }) @@ -1335,8 +1348,8 @@ class MainProcessBridge implements MainIpcModule { } return installResult } - handleLibrariesUninstall = async (_event: IpcMainInvokeEvent, name: string) => { - const result = this.libraryManagerModule.uninstall(name) + handleLibrariesUninstall = async (_event: IpcMainInvokeEvent, name: string, version?: string) => { + const result = this.libraryManagerModule.uninstall(name, version) if (result.success) { this.mainWindow?.webContents.send('libraries:changed') } @@ -2207,8 +2220,8 @@ class MainProcessBridge implements MainIpcModule { * included); missing names come back for the caller to surface. Feeds both builds, so * verify can't drift from the actual compile. */ - loadEnabledArchives = (enabledNames: string[]): { archives: unknown[]; missing: string[] } => - this.libraryManagerModule.loadEnabledArchives(enabledNames) + loadEnabledArchives = (refs: ReadonlyArray): EnabledArchives => + this.libraryManagerModule.loadEnabledArchives(refs) // TODO: These handlers are outdated and should be removed. // handleCompilerSetupEnvironment = (event: IpcMainEvent) => { @@ -3502,6 +3515,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 404d368b5..0eaffd69b 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -210,8 +210,8 @@ const rendererProcessBridge = { | { success: true; canceled: true } | { success: false; error: string } > => ipcRenderer.invoke('libraries:install-from-file'), - uninstallLibrary: (name: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('libraries:uninstall', name), + uninstallLibrary: (name: string, version?: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('libraries:uninstall', name, version), // ----- Public-library catalog (autonomy-edge) ----- queryPublicCatalog: ( args: ListPublicLibrariesArgs, @@ -954,6 +954,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/__tests__/accelerator-adapter.test.ts b/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts index c402d2bc2..11979ec20 100644 --- a/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts @@ -123,17 +123,30 @@ testAccelerator('onQuitApp', 'quitApp', 'quitAppRequest') testAccelerator('onRefresh', 'refresh', 'refreshRequest') describe('onOpenRecent', () => { - it('registers a bridge listener and passes response data to the callback', () => { + // The channel carries the PATH of the project to open, and nothing else. + // It used to carry the main process's whole `openProjectByPath` response, + // which the renderer passed on as if it were the parsed payload — so every + // Open Recent set the project's `meta` and `data` to undefined and the first + // component to read them threw. + it('registers a bridge listener and passes the project path to the callback', () => { const cb = jest.fn() adapter.onOpenRecent(cb) expect(window.bridge.openRecentAccelerator).toHaveBeenCalledTimes(1) - const mockEvent = {} - const mockResponse = { projectPath: '/some/path' } - fire('openRecent', mockEvent, mockResponse) + fire('openRecent', {}, '/some/path') - expect(cb).toHaveBeenCalledWith(mockResponse) + expect(cb).toHaveBeenCalledWith('/some/path') + }) + + it('passes undefined rather than a payload it cannot use', () => { + const cb = jest.fn() + adapter.onOpenRecent(cb) + + // A version-skewed main process still sending the old response envelope. + fire('openRecent', {}, { success: true, data: { meta: {}, content: {} } }) + + expect(cb).toHaveBeenCalledWith(undefined) }) it('returns an unsubscribe function that deactivates the callback', () => { @@ -141,7 +154,7 @@ describe('onOpenRecent', () => { const unsub = adapter.onOpenRecent(cb) unsub() - fireIfRegistered('openRecent', {}, { projectPath: '/x' }) + fireIfRegistered('openRecent', {}, '/x') expect(cb).not.toHaveBeenCalled() }) diff --git a/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts b/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts index 32a812021..5fcacdb6d 100644 --- a/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts @@ -631,6 +631,26 @@ describe('createEditorCompilerAdapter', () => { }) describe('compileLibrary', () => { + it("hands the installed libraries' function blocks to the POU preprocessor", async () => { + // A library's own Python POU may instantiate a function block that another + // library ships, so the preprocessor needs the same pin source a program + // build gets. With no library installed the list is empty and the mapping + // never runs, which is why it takes an installed one to exercise. + ;(window.bridge.loadAllLibraries as jest.Mock).mockResolvedValue([ + { manifest: { name: 'motor_lib', functionBlocks: [{ name: 'Driver', inputs: [], outputs: [], inouts: [] }] } }, + ]) + + const promise = adapter.compileLibrary!({ projectData: mockProjectData, projectPath: '/lib/project' }, () => {}) + await flushMicrotasks() + libraryCallback!({ libraryBuildResult: { success: true, libraryName: 'demo_lib' } }) + libraryCallback!({ closePort: true }) + + await promise + + expect(window.bridge.loadAllLibraries).toHaveBeenCalled() + expect(window.bridge.runCompileLibrary).toHaveBeenCalled() + }) + it('posts project path + IPC data to runCompileLibrary and resolves the structured result', async () => { const progressEvents: CompileProgressEvent[] = [] const promise = adapter.compileLibrary!({ projectData: mockProjectData, projectPath: '/lib/project' }, (event) => @@ -651,16 +671,18 @@ describe('createEditorCompilerAdapter', () => { const result = await promise - // Args: [projectPath, ipcDataForBuild, nativePous] - // 3rd arg: the native-POU inventory, taken from the RAW project data + // Args: [projectPath, ipcDataForBuild, ipcDataForVerify, cleanBuild, nativePous] + // 5th arg: the native-POU inventory, taken from the RAW project data // before `preprocessPous` lowered every native body to bridge ST and // rewrote its language tag. The main process cannot derive it. - // - // There is no verify-pass project data and no `cleanBuild` flag: the - // build runs strucpp and nothing else, so there is no second preprocess - // pass to send and no verification cache to bypass. expect(window.bridge.runCompileLibrary).toHaveBeenCalledWith( - ['/lib/project', expect.objectContaining({ pous: expect.any(Array) }), expect.any(Array)], + [ + '/lib/project', + expect.objectContaining({ pous: expect.any(Array) }), + expect.objectContaining({ pous: expect.any(Array) }), + false, + expect.any(Array), + ], expect.any(Function), ) expect(result).toEqual({ @@ -770,7 +792,7 @@ describe('createEditorCompilerAdapter', () => { await promise const args = (window.bridge.runCompileLibrary as jest.Mock).mock.calls[0][0] as unknown[] - expect(args[2]).toEqual([{ name: 'CPP_SCALE', language: 'cpp', relPath: 'pous/function-blocks/CPP_SCALE.cpp' }]) + expect(args[4]).toEqual([{ name: 'CPP_SCALE', language: 'cpp', relPath: 'pous/function-blocks/CPP_SCALE.cpp' }]) }) it('defaults non-error log levels to info when logLevel is missing', async () => { diff --git a/src/middleware/adapters/editor/__tests__/library-adapter.test.ts b/src/middleware/adapters/editor/__tests__/library-adapter.test.ts index bf01b132c..a0f6ddfe2 100644 --- a/src/middleware/adapters/editor/__tests__/library-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/library-adapter.test.ts @@ -71,10 +71,16 @@ describe('uninstall', () => { it('normalises a successful bridge response into a Result', async () => { const result = await adapter.uninstall('oscat') - expect(window.bridge.uninstallLibrary).toHaveBeenCalledWith('oscat') + expect(window.bridge.uninstallLibrary).toHaveBeenCalledWith('oscat', undefined) expect(result).toEqual({ success: true }) }) + it('forwards a version so one build can be removed', async () => { + await adapter.uninstall('oscat', '1.2.3') + + expect(window.bridge.uninstallLibrary).toHaveBeenCalledWith('oscat', '1.2.3') + }) + it('passes through the bridge-supplied error', async () => { ;(window.bridge.uninstallLibrary as jest.Mock).mockResolvedValueOnce({ success: false, diff --git a/src/middleware/adapters/editor/accelerator-adapter.ts b/src/middleware/adapters/editor/accelerator-adapter.ts index 7670e55d8..41a348b36 100644 --- a/src/middleware/adapters/editor/accelerator-adapter.ts +++ b/src/middleware/adapters/editor/accelerator-adapter.ts @@ -45,8 +45,10 @@ export function createEditorAcceleratorAdapter(): AcceleratorPort { return window.bridge.handleOpenProjectRequest(() => callback()) }, - onOpenRecent(callback: (projectData?: unknown) => void): Unsubscribe { - return window.bridge.openRecentAccelerator((_event: unknown, response: unknown) => callback(response)) + onOpenRecent(callback: (projectPath?: string) => void): Unsubscribe { + return window.bridge.openRecentAccelerator((_event: unknown, projectPath: unknown) => + callback(typeof projectPath === 'string' ? projectPath : undefined), + ) }, onSaveProject(callback: () => void): Unsubscribe { diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index 93bfd0683..cdef5bb03 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 ?? '', }, @@ -143,6 +147,10 @@ export type CompileLibraryIpcArgs = [ projectPath: string, /** Build-pass project data, `preprocessPous` with `isSimulator: false`. */ projectData: IpcProjectData, + /** Verification-pass project data, `preprocessPous` with `isSimulator: true`. */ + verifyProjectData: IpcProjectData, + /** Skip the verification cache and verify again. */ + cleanBuild: boolean, /** * Native (C/C++, Python) POUs collected from the RAW project data before * preprocessing lowered every native body to bridge ST — the main process @@ -293,11 +301,14 @@ export function createEditorCompilerAdapter(deps: EditorCompilerAdapterDeps = {} // build. The `.stlib` ships real Python code, usable by any // consumer that targets a Python-capable runtime. // - // There used to be a second `isSimulator: true` pass feeding an - // avr-gcc verification compile, which stubbed Python POUs to - // no-ops because the AVR simulator has no interpreter. The - // verification stage is gone: the build is target-neutral, and - // running a library goes through the debug harness instead. + // A second `isSimulator: true` pass feeds the verification compile. It + // stubs Python POUs to no-ops, because the toolchain behind a + // verification compile has no interpreter — what verification proves is + // that the library's ST, IL and data types compile cleanly, not that its + // Python runs. + // + // Only the build pass's preprocess output reaches `onProgress`, so the + // console does not carry every "Found Python POU…" line twice. // // Taken BEFORE preprocessing: that step lowers every native body to // bridge ST and rewrites the language tag with it, leaving nothing to @@ -332,7 +343,30 @@ export function createEditorCompilerAdapter(deps: EditorCompilerAdapterDeps = {} 'POU validation failed. Check C/C++ code for missing setup()/loop() functions.', } } + const verifyResult = preprocessPous( + args.projectData, + true, + () => { + // Silent — the build pass above already logged this project. + }, + undefined, + fbSources, + ) + // Checked rather than ignored, matching the build pass above. Both passes + // get the same project and the same FB pin sources, so they agree today — + // but handing an un-lowered project to the verify compile would fail it on + // `python_block_loader` instead of reporting the refusal, and that is not + // a difference worth leaving to luck. + if (verifyResult.validationFailed) { + return { + success: false, + error: + verifyResult.validationError ?? + 'POU validation failed. Check C/C++ code for missing setup()/loop() functions.', + } + } const ipcDataForBuild = toIpcProjectData(buildResult.projectData) + const ipcDataForVerify = toIpcProjectData(verifyResult.projectData) return new Promise((resolve) => { let finalResult: CompileLibraryResult | undefined @@ -351,7 +385,7 @@ export function createEditorCompilerAdapter(deps: EditorCompilerAdapterDeps = {} // `'close'` event — that's the sole "build done" // signal the adapter resolves on. window.bridge.runCompileLibrary( - [args.projectPath, ipcDataForBuild, nativePous], + [args.projectPath, ipcDataForBuild, ipcDataForVerify, args.cleanBuild ?? false, nativePous], (data: Record) => { if (data.libraryBuildResult) { finalResult = data.libraryBuildResult as CompileLibraryResult diff --git a/src/middleware/adapters/editor/library-adapter.ts b/src/middleware/adapters/editor/library-adapter.ts index 0ce2b9496..4d2767ad1 100644 --- a/src/middleware/adapters/editor/library-adapter.ts +++ b/src/middleware/adapters/editor/library-adapter.ts @@ -48,8 +48,8 @@ export function createEditorLibraryAdapter(): LibraryPort { return window.bridge.installLibraryFromFile() }, - async uninstall(name: string): Promise { - const result = await window.bridge.uninstallLibrary(name) + async uninstall(name: string, version?: string): Promise { + const result = await window.bridge.uninstallLibrary(name, version) if (result.success) return { success: true } as Result return { success: false, error: result.error ?? 'Uninstall failed' } }, diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index efe3bc762..cafc7fcdb 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -63,6 +63,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 @@ -104,6 +106,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 { @@ -111,6 +116,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'], @@ -129,6 +135,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 ?? '', }, @@ -589,6 +596,18 @@ export function createEditorProjectAdapter(account: EdgeAccountPort = editorEdge }) }, + 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/accelerator-port.ts b/src/middleware/shared/ports/accelerator-port.ts index a1ff8e290..2bd223dc2 100644 --- a/src/middleware/shared/ports/accelerator-port.ts +++ b/src/middleware/shared/ports/accelerator-port.ts @@ -39,7 +39,8 @@ export interface AcceleratorPort { // --- Project actions --- onCreateProject(callback: () => void): Unsubscribe onOpenProject(callback: () => void): Unsubscribe - onOpenRecent(callback: (projectData?: unknown) => void): Unsubscribe + /** Fires with the PATH of the recent project to open. */ + onOpenRecent(callback: (projectPath?: string) => void): Unsubscribe onSaveProject(callback: () => void): Unsubscribe /** Save As. Desktop only in practice; web's adapter returns a no-op * unsubscribe like its other accelerators. */ diff --git a/src/middleware/shared/ports/block-types.ts b/src/middleware/shared/ports/block-types.ts index d87470a42..a88fac741 100644 --- a/src/middleware/shared/ports/block-types.ts +++ b/src/middleware/shared/ports/block-types.ts @@ -14,6 +14,13 @@ const blockVariantVariableSchema = BaseLibraryVariableSchema.extend({ definition: z.literal('generic-type'), value: genericTypeSchema.keyof(), }), + // A type the library or the project declares -- an enum, struct or alias. + // Library pins carry these (`library-types.ts` SystemLibraryVariable), so + // the placed pin has to be able to hold one too. + z.object({ + definition: z.literal('derived-type'), + value: z.string(), + }), ]), }) diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index 1675e990d..35337ee5b 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,7 +153,8 @@ export interface CompileArduinoResult { export interface UploadRuntimeV4Args { /** File map the runtime extracts on the device. Already * composed by `composeRuntimeV4Bundle`; the pipeline passes it - * straight through. */ + * straight through. A `{ base64 }` entry carries bytes — see + * `RuntimeV4Bundle`. */ bundle: RuntimeV4Bundle /** Discriminated device context; see `PlatformDeviceContext`. */ context: PlatformDeviceContext @@ -301,12 +307,13 @@ export interface CheckRuntimeVersionResult { /** * Path → content of a runtime-v4 upload bundle. * - * Text for everything the compile pipeline composes; `Uint8Array` exists for - * VPP plugin payloads, which may be precompiled objects (`hal.provisioning - * === 'prebuilt'`). Routing those through a JS string would corrupt them, and - * the corruption would only surface as a link failure on the device. + * Text for most of what the compile pipeline composes, and `{ base64 }` for a + * library resource's bytes (see `BundleFile`). `Uint8Array` exists for VPP + * plugin payloads, which may be precompiled objects (`hal.provisioning === + * 'prebuilt'`). Routing those through a JS string would corrupt them, and the + * corruption would only surface as a link failure on the device. */ -export type RuntimeV4Bundle = Record +export type RuntimeV4Bundle = Record /** VPP (Vendor Plugin Package) runtime-v4 packaging. Boards that * come from an installed `.vpp` package ship a vendor I/O driver @@ -446,7 +453,8 @@ export interface CompilerPlatformPort { } export interface MaterializeRuntimeV4BundleArgs { - /** Path → file content, as composed by `composeRuntimeV4Bundle`. */ + /** Path → file content, as composed by `composeRuntimeV4Bundle`. A + * `{ base64 }` or `Uint8Array` entry carries bytes — see `RuntimeV4Bundle`. */ bundle: RuntimeV4Bundle } diff --git a/src/middleware/shared/ports/compiler-port.ts b/src/middleware/shared/ports/compiler-port.ts index b0757cee7..19c383dc7 100644 --- a/src/middleware/shared/ports/compiler-port.ts +++ b/src/middleware/shared/ports/compiler-port.ts @@ -77,6 +77,15 @@ export interface ExportXmlArgs { export interface CompileLibraryArgs { projectData: PLCProjectData projectPath: string + /** + * Skip the verification-result cache for this run. The cache normally + * short-circuits the verification compile when nothing it was keyed on has + * changed since the last run; `cleanBuild: true` forces a fresh one. + * + * Pure UX gate — the artefact build itself is always fresh; only the + * verification step is cached. + */ + cleanBuild?: boolean } export interface CompilerPort { @@ -115,10 +124,11 @@ export interface CompilerPort { * `projectCapabilities(meta).hasLibraryBuild`. * * Returns the artefact path on success, or an error string the - * console renders directly. The build is target-neutral: it runs - * strucpp and nothing else, so a library that could never link on - * one particular board still produces a valid archive. Executing - * the library is a separate action — see `composeLibraryDebugHarness`. + * console renders directly. A verification compile against the target the + * manifest names runs alongside it and reports through `verification`; + * failing it never fails the build, because the archive carries source and + * the consumer compiles it for its own board. Executing the library is a + * separate action — see `composeLibraryDebugHarness`. */ compileLibrary?( args: CompileLibraryArgs, diff --git a/src/middleware/shared/ports/index.ts b/src/middleware/shared/ports/index.ts index f9f14e807..0ba5e96e3 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 { FetchedProject, RetrievableDevice, 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 cd5ecb01e..d7378aad7 100644 --- a/src/middleware/shared/ports/library-build-port.ts +++ b/src/middleware/shared/ports/library-build-port.ts @@ -8,10 +8,10 @@ * primitives the orchestrator cannot perform itself because they * cross the platform boundary (filesystem ↔ HTTP, etc.). * - * The port used to carry a `verifyCompile` primitive that drove the - * library through avr-gcc against the OpenPLC Simulator board on - * every build. It is gone: the build is target-neutral, and running - * a library is now an explicit action driven from the renderer + * `verifyCompile` drives the library through the toolchain the + * manifest's `build` block names, so a block that does not compile is + * reported at build time rather than by whoever installs the archive. + * Running a library is a separate action driven from the renderer * through `CompilerPort.compileProgram` with a generated harness * project (`composeLibraryDebugHarness`). * @@ -37,6 +37,20 @@ import type { TranspileToStArgs, TranspileToStResult } from './compiler-platform-port' +/** + * 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. + */ +export interface LibraryVerificationResult { + success: boolean + /** Human-readable summary of the failure, surfaced as a console + * warning when `success` is false. Undefined on success. */ + message?: string +} + /** * Library-enabled archives the project pulls in for the strucpp * compile. `archives` carries the bundled IEC standard set PLUS any @@ -54,19 +68,66 @@ 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. */ + projectPath: string + /** + * Verification-pass project data — Python POUs already lowered to + * no-op stubs (the AVR simulator has no Python interpreter). The + * orchestrator preprocesses this separately from the build pass + * and hands the result through. + * + * Typed as `unknown` on purpose: the architecture rule forbids the + * port from importing `backend/shared` types. The orchestrator + * lives in `backend/shared` and produces shape-correct data; the + * port impl casts to its platform's expected shape (port-shape on + * web before invoking `runCompilePipeline`, schema-shape on editor + * 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. */ + emit: (message: string, level: 'info' | 'warning' | 'error') => void +} + export interface LibraryBuildPort { // ------------------------------------------------------------------------- - // Transpile (same signature `CompilerPlatformPort` uses for the - // program build — declared here too so the orchestrator takes a - // single port object instead of two. Each impl is free to delegate - // to whatever its program-build path uses internally; the contract - // is just that bytes in match bytes out.) - // - // There was a `computeMd5` primitive here as well, hashing - // `program.st` to key the verification cache. Both went out with - // the verification stage. + // Cryptography + transpile (same signatures `CompilerPlatformPort` + // uses for the program build — declared here too so the orchestrator + // takes a single port object instead of two. Each impl is free to + // delegate to whatever its program-build path uses internally; the + // contract is just that bytes in match bytes out.) // ------------------------------------------------------------------------- + /** MD5 hex digest. Editor wires it to Node's `crypto`; web wires + * it to `spark-md5`. Both byte-identical. */ + computeMd5(input: string): Promise + /** * Transpile the project IR directly to ST via the in-process * JSON transpiler (`st-transpiler`). Both editor and @@ -98,6 +159,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 @@ -120,4 +206,17 @@ export interface LibraryBuildPort { * the build with a clear "Library Manager" message. */ loadLibraryArchives(args: LibraryArchiveLookupArgs): Promise + + /** + * 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..608c946b7 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 { @@ -127,9 +141,11 @@ export interface LibraryPort { /** * Open the platform's file picker and install whatever the user - * selects. Accepts `.stlib` archives (native strucpp format) and + * selects. Accepts `.stlib` archives (native strucpp format), * `.lib`/`.library` files (CODESYS, run through strucpp's - * importer to produce a `.stlib`). Returns `{ canceled: true }` + * importer to produce a `.stlib`), and a `.zip` holding any + * number of those files — which installs every one of them and + * reports each separately. Returns `{ canceled: true }` * when the user dismisses the picker. */ installFromFile(): Promise @@ -138,8 +154,11 @@ export interface LibraryPort { * Remove a user-installed library from the system pool. Refuses * for bundled libraries — those are always-on; the caller should * disable them via project membership instead. + * + * `version` removes just that one build; omitted removes every + * installed version of the library. */ - uninstall(name: string): Promise + uninstall(name: string, version?: string): Promise /** * Subscribe to system-pool change events fired after install / diff --git a/src/middleware/shared/ports/library-types.ts b/src/middleware/shared/ports/library-types.ts index 7f12930c9..ad0c69716 100644 --- a/src/middleware/shared/ports/library-types.ts +++ b/src/middleware/shared/ports/library-types.ts @@ -119,6 +119,10 @@ export interface InstalledLibrary { origin: 'stlib' | 'codesys' | 'bundled' /** Optional human-readable label from the manifest. Falls back * to `name` in the UI. */ + /** Every installed version of this library, newest first. `version` + * above is the first of these. Absent for bundled libraries, which + * ship one build with the editor. */ + versions?: string[] displayName?: string /** Optional manifest descriptions surfaced in the manager's * details panel. Subset of the `.stlib` manifest — only the @@ -133,7 +137,10 @@ export interface InstalledLibrary { * .lib/.library paths funnel through this shape so the renderer * doesn't branch on origin. */ -export type LibraryInstallResult = +export type LibraryInstallResult = LibrarySingleInstallResult | LibraryBundleInstallResult + +/** Installing one archive: what every path except a ZIP returns. */ +export type LibrarySingleInstallResult = | { success: true /** True when the user closed the file picker without choosing @@ -148,3 +155,49 @@ export type LibraryInstallResult = } | { success: true; canceled: true } | { success: false; error: string } + +/** One `.stlib` inside a ZIP, and what installing it did. */ +export type LibraryBundleEntry = + | { path: string; success: true; name: string; version: string; origin: 'stlib' | 'codesys' } + | { path: string; success: false; error: string } + +/** + * A ZIP holds several archives, so one of them failing says nothing about the + * rest: each is installed on its own and reported on its own. `success` is + * true whenever at least one landed — the caller refreshes for those and + * surfaces `failed` for the others. Narrow with `'entries' in result`. + */ +export interface LibraryBundleInstallResult { + success: true + canceled?: false + /** Every `.stlib` the ZIP held, in the order they were installed. */ + entries: LibraryBundleEntry[] + /** The ones that installed, so the caller can select one and refresh. */ + installed: Array<{ name: string; version: string }> + /** The ones that did not, each naming its entry path. */ + failed: Array<{ path: string; error: string }> +} + +/** A project's reference to a library: a name, optionally pinned to a version. */ +export interface LibraryRef { + name: string + version?: string +} + +/** The pinned version was not installed, so a different one was used. */ +export interface VersionSubstitution { + name: string + wanted: string + used: string +} + +/** What a compile gets for the libraries a project enables. + * Parameterised so the resolver can keep its parsed archive type while the + * IPC boundary, which cannot, stays on `unknown`. */ +export interface EnabledArchives { + archives: Archive[] + /** Enabled names no archive could be resolved for. */ + missing: string[] + /** Resolved, but not to the version the project pins. */ + substituted: VersionSubstitution[] +} 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 5da5a3914..5f4393826 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -4,6 +4,17 @@ import { z } from 'zod' import type { PrintRequest } from './print-types' 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' @@ -344,6 +355,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 }> + pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> exportPlcopenFile(defaultFileName: string, xml: string): Promise<{ success: boolean; error?: string }> diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index b4b7f7d38..43cfefa30 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -101,6 +101,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 @@ -1061,7 +1067,18 @@ export interface DebugCompileResult { error?: string } -/** Deliberately carries no verification field: running a library is its own action, via the debug harness. */ +/** + * Result of building a `.stlib` from a Library Project. Mirrors the + * shape of `CompileResult` (success / error) plus the artefact path + * the console surfaces so the user can find the produced archive. + * + * 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 /** Absolute path, set only on success. */ @@ -1069,6 +1086,10 @@ export interface CompileLibraryResult { /** Manifest name extracted from `library.json`. */ libraryName?: string error?: string + verification?: { + success: boolean + message?: string + } } /** Function Block Instance Info — represents a specific FB instance for debugging */ 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 +}