From 009c7106f24aa756698099d0b18e8b1c6e1c7204 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Thu, 27 Aug 2026 14:28:41 +1200 Subject: [PATCH 01/13] feat(library): ship C/C++ resources in the .stlib `resources/` holds one folder per C/C++ library, packaged verbatim so a block's #include resolves without the consumer installing anything. A `build` block in library.json picks the core that verifies it, edited from the new Build Settings screen. --- scripts/link-modules.ts | 9 + .../editor/compiler/compiler-module.ts | 125 ++++-- .../compiler/desktop-library-build-port.ts | 41 +- src/backend/editor/services/index.ts | 1 + .../library-resources-service.test.ts | 139 +++++++ .../library-resources-service/index.ts | 212 ++++++++++ .../project-service/utils/create-project.ts | 17 +- .../__tests__/compose-firmware-bundle.test.ts | 64 +++ .../shared/compile/__tests__/pipeline.test.ts | 109 +++++ src/backend/shared/compile/pipeline.ts | 63 +++ .../compile/steps/compose-firmware-bundle.ts | 54 ++- .../__tests__/build-arduino-cli-args.test.ts | 35 ++ .../shared/firmware/build-arduino-cli-args.ts | 9 + .../library/__tests__/build-pipeline.test.ts | 169 ++++++++ .../library-build-orchestrator.test.ts | 261 +++++++++++- src/backend/shared/library/build-pipeline.ts | 58 ++- src/backend/shared/library/compile-stlib.ts | 3 + .../library/library-build-orchestrator.ts | 106 ++++- .../__tests__/create-project-files.test.ts | 12 + .../shared/project/create-project-files.ts | 46 ++- .../cpp/__tests__/generateCBlocksCode.test.ts | 20 +- .../__tests__/generateCBlocksHeader.test.ts | 13 +- .../shared/utils/cpp/generateCBlocksCode.ts | 27 +- .../shared/utils/cpp/generateCBlocksHeader.ts | 15 +- src/backend/shared/utils/path-safety.ts | 16 + src/frontend/components/_atoms/tab/index.tsx | 5 + .../[workspace]/build-options/index.tsx | 2 +- .../editor/build-settings/index.tsx | 143 +++++++ .../editor/build-settings/resources-tab.tsx | 266 ++++++++++++ .../build-settings/verify-target-tab.tsx | 386 ++++++++++++++++++ .../editor/device/configuration/board.tsx | 17 +- .../library-manager/project-libraries-tab.tsx | 5 +- .../_molecules/breadcrumbs/index.tsx | 14 + .../_molecules/project-tree/index.tsx | 9 +- .../_organisms/explorer/project.tsx | 47 ++- src/frontend/screens/workspace-screen.tsx | 21 +- .../services/open-package-manager-tab.ts | 29 ++ src/frontend/store/slices/editor/types.ts | 8 + src/frontend/store/slices/tabs/types.ts | 1 + src/frontend/store/slices/tabs/utils.ts | 14 + src/frontend/store/slices/workspace/types.ts | 1 + .../PLC/__tests__/pou-text-parser.test.ts | 20 + src/frontend/utils/PLC/pou-text-parser.ts | 20 +- .../cpp/__tests__/generateSTCode.test.ts | 18 +- src/frontend/utils/cpp/generateSTCode.ts | 13 +- src/main/main.ts | 12 + src/main/modules/ipc/main.ts | 51 +++ src/main/modules/ipc/renderer.ts | 17 + .../adapters/editor/project-adapter.ts | 12 + src/middleware/shared/ports/index.ts | 2 +- .../shared/ports/library-build-port.ts | 50 ++- src/middleware/shared/ports/library-port.ts | 10 + src/middleware/shared/ports/project-port.ts | 35 ++ src/middleware/shared/ports/types.ts | 12 +- .../compose-runtime-v4-bundle.test.ts | 44 ++ .../__tests__/manifest-build-block.test.ts | 67 +++ .../__tests__/pick-verify-board.test.ts | 34 ++ .../library/compose-runtime-v4-bundle.ts | 23 +- .../utils/library/manifest-build-block.ts | 107 +++++ .../shared/utils/library/pick-verify-board.ts | 40 ++ 60 files changed, 2997 insertions(+), 182 deletions(-) create mode 100644 src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts create mode 100644 src/backend/editor/services/library-resources-service/index.ts create mode 100644 src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/build-settings/verify-target-tab.tsx create mode 100644 src/frontend/services/open-package-manager-tab.ts create mode 100644 src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts create mode 100644 src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts create mode 100644 src/middleware/shared/utils/library/manifest-build-block.ts create mode 100644 src/middleware/shared/utils/library/pick-verify-board.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/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index f08e2c132..4ec5d6cfb 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 { RUNTIME_API_PORT } from '@root/backend/editor/runtime/runtime-api-client' import { resolveTrustedKeysArtifact } from '@root/backend/shared/compile/steps/generate-trusted-keys' import type { VppModbusScreenState } from '@root/backend/shared/compile/steps/modbus-defines' @@ -34,6 +35,8 @@ import { transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' import type { KnownPou } from '@root/backend/shared/utils/PLC/split-program-st' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { pickVerifyBoard } from '@root/middleware/shared/utils/library/pick-verify-board' /** * Shared bridge contract between `compileLibrary` and its inner @@ -93,6 +96,15 @@ type ProjectDataWithCppPous = PLCProjectData & { const POST_BUILD_START_TIMEOUT_MS = 5000 const POST_BUILD_START_POLL_INTERVAL_MS = 150 +/** + * Boards a library verification resolves to by name rather than through the + * catalogue. The simulator stands in whenever an Arduino target cannot be + * resolved — it is bundled, so it is the one board always installed. + */ +const SIMULATOR_BOARD = 'OpenPLC Simulator' +const SIMULATOR_CORE = 'arduino:avr' +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' @@ -121,6 +133,7 @@ import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' +import type { AvailableBoards } from '../hardware/types' import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' @@ -396,21 +409,64 @@ class CompilerModule { } /** - * Resolve a board target to the arduino-cli core ID - * (`arduino-cli core install` target — e.g. `arduino:avr`). + * Absolute paths to the library folders the firmware bundle materialised + * under `/libraries/`. arduino-cli compiles a library's + * `src/` only when the folder is named with its own `--library`, so each + * has to reach the command line. + */ + 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. * - * Single source of truth: reads from the shared - * `backend/shared/firmware/hals.json` bundle, the same file the - * renderer's `bridge.getAvailableBoards()` exposes via - * `boardInfo.core`. - * Used internally by the library-project verification path so a - * future hals.json edit (rename, new board, version bump) - * propagates to verification automatically — without any code - * change here. + * 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 #getBoardCore(board: string): Promise { - const halsFileContent = await readHalsFile() - return halsFileContent[board]?.['core'] ?? null + async #resolveVerifyBoard( + target: LibraryVerifyTarget, + warn: (message: string) => void, + ): Promise<{ board: string; core: string | null }> { + if (target.mode === 'runtime') { + return { board: RUNTIME_V4_BOARD, core: null } + } + if (!target.core) { + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + + let boards: AvailableBoards + try { + boards = await new HardwareModule().getAvailableBoards() + } catch (error) { + warn(`Could not read the board catalogue (${getErrorMessage(error)}) — verifying against ${SIMULATOR_BOARD}.`) + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + + const chosen = pickVerifyBoard( + [...boards.entries()].map(([name, info]) => ({ name, core: info.core, compiler: info.compiler })), + target.core, + ) + if (!chosen) { + warn(`No board is installed for core "${target.core}" — verifying against ${SIMULATOR_BOARD}.`) + return { board: SIMULATOR_BOARD, core: SIMULATOR_CORE } + } + return { board: chosen, core: target.core } } /** @@ -1804,6 +1860,7 @@ class CompilerModule { ...buildArduinoCliCompileArgs(compileEntry, { sketchPath: join(baremetalPath, 'Baremetal.ino'), libraryPath: join(compilationPath, 'src'), + resourceLibraryPaths: await this.#resourceLibraryDirs(compilationPath), avrLibStdCppInclude, cleanBuild, }), @@ -3314,13 +3371,13 @@ class CompilerModule { * 5. Write the archive (same `JSON.stringify(archive, null, 2)` * shape `library-manager-module` persists user-installed * archives with) to `/build/.stlib`. - * 6. (Phase 8) Run an end-to-end avr-gcc verification compile - * against the OpenPLC Simulator target, gated by an MD5 - * cache keyed off the produced program.st. Verification - * failures surface as warnings on `result.verification`, - * never as build errors — a legitimate user target may have - * more memory than the AVR simulator. `cleanBuild` skips - * the cache and forces a re-verification. + * 6. Run an end-to-end verification compile against the target the + * manifest's `build` block names, gated by an MD5 cache keyed off + * the verified sources and that target. Verification failures + * surface as warnings on `result.verification`, never as build + * errors — the `.stlib` carries source and the consumer compiles it + * for its own board. `cleanBuild` skips the cache and forces a + * re-verification. */ async compileLibrary( args: Array, @@ -3346,8 +3403,8 @@ class CompilerModule { // the shared orchestrator from here on. const libraryPort = createDesktopLibraryBuildPort({ loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), - runVerificationCompile: ({ projectPath: p, verifyProjectData: v, emit }) => - this.runVerificationCompile(p, v as PLCProjectData, mainProcessBridge, (message, logLevel) => + runVerificationCompile: ({ projectPath: p, verifyProjectData: v, target, emit }) => + this.runVerificationCompile(p, v as PLCProjectData, target, mainProcessBridge, (message, logLevel) => emit(message, logLevel), ), }) @@ -3371,9 +3428,9 @@ class CompilerModule { /** * Run an end-to-end verification compile of a synthetic Library - * Project against the OpenPLC Simulator target. Reuses the full - * `compileProgram` pipeline (strucpp → arduino-cli → bundled - * avr-gcc) by feeding it a private `MessageChannelMain` — verifies + * Project against the manifest's verify target. Reuses the full + * `compileProgram` pipeline (strucpp → arduino-cli → the core's + * toolchain) by feeding it a private `MessageChannelMain` — verifies * the same way the program build does, against the same binaries, * with zero code duplication. * @@ -3396,17 +3453,17 @@ class CompilerModule { private async runVerificationCompile( projectPath: string, verifyData: PLCProjectData, + target: LibraryVerifyTarget, bridge: LibraryVerificationBridge, forwardLog: (message: string, logLevel?: 'info' | 'warning' | 'error') => void, ): Promise<{ success: boolean; message?: string }> { - // Look up the simulator board's core ID from `hals.json` — - // single source of truth shared with the renderer-side - // `boardInfo.core` lookup. Falls back to a sensible default - // only if hals.json has been mangled; the resulting compile - // would fail at `core install` and surface as a verification - // warning, which is the documented advisory behaviour. - const SIMULATOR_BOARD = 'OpenPLC Simulator' - const boardCore = (await this.#getBoardCore(SIMULATOR_BOARD)) ?? 'arduino:avr' + const { board, core: boardCore } = await this.#resolveVerifyBoard(target, (message) => + forwardLog(message, 'warning'), + ) + // Name the board, not just the core: which board stands in for a core + // decides the FQBN and the defines, so a compile error that only that + // board produces is otherwise unattributable. + forwardLog(`Verifying against ${board}${boardCore ? ` (${boardCore})` : ''}.`, 'info') return new Promise((resolve) => { const channel = new MessageChannelMain() @@ -3470,7 +3527,7 @@ class CompilerModule { // values the inner `compileProgram` re-casts off `args as [...]`, const compileArgs: Array = [ projectPath, - SIMULATOR_BOARD, + board, boardCore, true, verifyData, diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index d1afb3504..9fa748b0a 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -31,7 +31,7 @@ import { transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' import type { TranspileToStArgs, TranspileToStResult } from '@root/middleware/shared/ports/compiler-platform-port' -import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-build-port' +import type { LibraryBuildPort, LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' /** * Subset of the desktop CompilerModule that the port leans on. @@ -49,15 +49,15 @@ export interface DesktopLibraryBuildPortDeps { loadEnabledArchives(enabledNames: string[]): { archives: unknown[]; missing: string[] } /** - * Run a verification compile against the OpenPLC Simulator board. - * Wraps `CompilerModule.runVerificationCompile` so the port stays - * decoupled from the compiler module's full surface. Failures - * here are advisory — caller surfaces them as warnings, never as - * a fatal build error. + * Run a verification compile against `target`. Wraps + * `CompilerModule.runVerificationCompile` so the port stays decoupled + * from the compiler module's full surface. Failures here are advisory — + * caller surfaces them as warnings, never as a fatal build error. */ runVerificationCompile(args: { projectPath: string verifyProjectData: unknown + target: LibraryVerifyTarget emit: (message: string, level?: 'info' | 'warning' | 'error') => void }): Promise<{ success: boolean; message?: string }> } @@ -115,6 +115,32 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) await fs.writeFile(fullPath, content, 'utf-8') }, + async listProjectFiles(projectPath: string, relPath: string): Promise { + const root = resolveProjectRelativePath(projectPath, relPath) + const walk = async (dir: string, prefix: string): Promise => { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch (error) { + if (isFsNotFound(error)) return [] + throw error + } + const found: string[] = [] + for (const entry of entries) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + // Symlinks are not followed: a link out of the tree would put + // arbitrary files into a published archive. + if (entry.isDirectory()) { + found.push(...(await walk(path.join(dir, entry.name), rel))) + } else if (entry.isFile()) { + found.push(rel) + } + } + return found + } + return (await walk(root, '')).sort() + }, + async deleteBuildSubtree(projectPath: string, relPath: string): Promise { const fullPath = resolveProjectRelativePath(projectPath, relPath) // `force: true` makes the call a no-op when the subtree is @@ -130,10 +156,11 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) return Promise.resolve(deps.loadEnabledArchives(projectLibraryRefs.map((r) => r.name))) }, - async verifyCompile({ projectPath, verifyProjectData, emit }) { + async verifyCompile({ projectPath, verifyProjectData, target, emit }) { return deps.runVerificationCompile({ projectPath, verifyProjectData, + target, // `runVerificationCompile` forwards every line off // `compileProgram`'s message port; pass them straight // through to the orchestrator's emit. diff --git a/src/backend/editor/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..a13e33008 --- /dev/null +++ b/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts @@ -0,0 +1,139 @@ +/** + * `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('leaves .git and node_modules behind', async () => { + const source = makeLibrary(sourceRoot, 'SensorKit') + mkdirSync(join(source, '.git'), { recursive: true }) + writeFileSync(join(source, '.git', 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(join(source, 'node_modules', 'dep'), { recursive: true }) + writeFileSync(join(source, 'node_modules', 'dep', 'index.js'), '\n') + + const result = await addLibraryResource(projectPath, source) + expect(result.success).toBe(true) + expect(result.folder?.files.some((file) => file.startsWith('.git/'))).toBe(false) + expect(result.folder?.files.some((file) => file.startsWith('node_modules/'))).toBe(false) + }) + + 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..165b6748e --- /dev/null +++ b/src/backend/editor/services/library-resources-service/index.ts @@ -0,0 +1,212 @@ +// 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 { assertPathContained } from '../../utils/path-containment' + +/** Project-relative directory the folders live in. */ +const RESOURCES_DIR = 'resources' + +/** + * Bounds on a folder being added. A picker makes it one click to choose a + * home directory by mistake, and the copy would otherwise run until the disk + * filled. Generous enough that no real library approaches them. + */ +const MAX_FILES = 2000 +const MAX_BYTES = 20 * 1024 * 1024 + +/** Directories never carried into `resources/`: version-control and + * dependency trees belong to the source repository, not to the library. */ +const SKIPPED_DIRS = new Set(['.git', 'node_modules']) + +/** 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. + } + + 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, + filter: (source) => !SKIPPED_DIRS.has(basename(source)), + }) + } 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) } + } +} + +/** + * 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) + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name)) stack.push(full) + continue + } + if (!entry.isFile()) 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 } +} + +/** Every file under `root`, relative and `/`-separated, sorted. Symlinks are + * not followed, matching what the build packages. */ +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) + if (entry.isDirectory()) { + stack.push(full) + } else if (entry.isFile()) { + found.push(relative(root, full).split(sep).join('/')) + } + } + } + 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/shared/compile/__tests__/compose-firmware-bundle.test.ts b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts index fff720c6c..0cc654ba7 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,72 @@ import { buildCBlocksFromPous, composeFirmwareBundle } from '../steps/compose-fi const baseInput = { strucppFiles: {}, + libraryResources: [] as Array<{ name: string; files: Array<{ path: string; content: string }> }>, 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("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 = { @@ -117,6 +178,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', @@ -148,6 +210,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', @@ -191,6 +254,7 @@ describe('composeFirmwareBundle — OpenPLCUserLib.h stub', () => { // with `fatal error: OpenPLCUserLib.h: No such file or directory`. it('always emits the stub under src/OpenPLCUserLib.h', () => { const out = composeFirmwareBundle({ + libraryResources: [], firmwareSkeleton: {}, strucppFiles: {}, cBlocks: { header: '', code: null }, diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index e49766067..64965b9cc 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -13,6 +13,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' +import { buildArduinoCliCompileArgs } from '../../firmware/build-arduino-cli-args' import type { PLCProjectData } from '../../types/PLC/open-plc' import type { CompilerPlatformPort, @@ -204,6 +205,114 @@ describe('runCompilePipeline — simulator path', () => { // was resolved onto boardEntry but never forwarded to installArduinoCore, // so vendor cores outside arduino-cli's built-in index could not be // installed — "Platform 'industrialshields:esp32' not found". + it('materialises resources from enabled libraries into the firmware bundle', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + libraries: [{ name: 'demo_lib', version: '0.1.0' }], + } as unknown as PLCProjectData, + libraryArchives: [ + { + manifest: { name: 'demo_lib' }, + // Two library folders in one archive's resources/ tree. + resources: [ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + { path: 'DemoProtocol/src/transport/Serial.cpp', content: '// serial\n' }, + { path: 'DemoSupport/src/DemoSupport.h', content: '// support\n' }, + ], + }, + // Resolved alongside the enabled set but not enabled by the + // project — its resources must not reach the build. + { + manifest: { name: 'oscat-basic' }, + resources: [{ path: 'Oscat/src/Oscat.h', content: '// oscat\n' }], + }, + ], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + // Each folder in resources/ becomes its own library, layout preserved. + expect(callArgs.files['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\n') + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('#pragma once\n') + expect(callArgs.files['libraries/DemoProtocol/src/transport/Serial.cpp']).toBe('// serial\n') + expect(callArgs.files['libraries/DemoSupport/src/DemoSupport.h']).toBe('// support\n') + + // Not enabled by the project, so neither its files nor its --library + // reach the build. + expect(callArgs.files['libraries/Oscat/src/Oscat.h']).toBeUndefined() + + // arduino-cli only recurses into a library that has a library.properties, + // so each folder has to be named on the command line as its own library. + const [, argOptions] = jest.mocked(buildArduinoCliCompileArgs).mock.calls.at(-1)! + expect(argOptions.resourceLibraryPaths).toEqual(['libraries/DemoProtocol', 'libraries/DemoSupport']) + }) + + it("materialises a library project's own resources when it verifies itself", async () => { + // A library project does not list itself in `libraries`, so without this + // its blocks resolve their includes against whatever is installed on the + // machine — or fail. + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + ownLibraryResources: [ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + ], + } as unknown as PLCProjectData, + libraryArchives: [], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('#pragma once\n') + + const [, argOptions] = jest.mocked(buildArduinoCliCompileArgs).mock.calls.at(-1)! + expect(argOptions.resourceLibraryPaths).toEqual(['libraries/DemoProtocol']) + }) + + it('skips a resource whose path would escape the build directory', async () => { + // Archive paths become files under the compilation path, and an installed + // `.stlib` is untrusted by the time a consuming project unpacks it. + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + projectData: { + ...projectDataFixture, + libraries: [{ name: 'demo_lib', version: '0.1.0' }], + } as unknown as PLCProjectData, + libraryArchives: [ + { + manifest: { name: 'demo_lib' }, + resources: [ + { path: '../../escaped.h', content: '// escaped\n' }, + { path: '/etc/passwd', content: '// absolute\n' }, + { path: 'DemoProtocol/../../escaped.h', content: '// traversal\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '// fine\n' }, + ], + }, + ], + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + const keys = Object.keys(callArgs.files) + expect(keys.some((k) => k.includes('..'))).toBe(false) + expect(keys.some((k) => k.includes('passwd'))).toBe(false) + expect(callArgs.files['libraries/DemoProtocol/src/DemoApi.h']).toBe('// fine\n') + }) + it('forwards boardEntry.boardManagerUrl to installArduinoCore', async () => { const port = makePort() const { emit } = captureEvents() diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index c13566bf9..a489e3ece 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -46,6 +46,7 @@ import type { DevicePin } from '../types/PLC/devices' // (plural `configurations`) and converts at the pipeline entry — see C1 // in the architectural plan. import type { PLCProjectData } from '../types/PLC/open-plc' +import { isSafeRelativePath } from '../utils/path-safety' import { buildCBlocksFromPous, composeFirmwareBundle } from './steps/compose-firmware-bundle' import { generateRuntimeConfs } from './steps/generate-confs' import { generateDefinesContent } from './steps/generate-defines' @@ -332,6 +333,63 @@ function bailError( * shape — `success`, `errors`, `binary`, `md5`, `uploaded` — that * adapters surface to their `CompilerPort` callers. */ +/** + * The libraries the enabled `.stlib` archives carry in `resources/`, grouped by + * folder. Each folder is an ordinary library — `library.properties` beside a + * `src/` directory — and is materialised as one. + * + * Filtered by the project's enabled libraries: the resolved archive set also + * holds the bundled ones. A file not inside a folder is skipped. On a + * same-named folder from two archives the later wins, and archive order is + * fixed. + */ +function collectLibraryResources( + projectData: PLCProjectData, + libraryArchives: unknown[], +): Array<{ name: string; files: Array<{ path: string; content: string }> }> { + const enabled = new Set((projectData.libraries ?? []).map((ref) => ref.name)) + + const byLibrary = new Map>() + const addResource = (resource: { path: string; content: string }): void => { + // These paths come off an installed archive and become files under the + // build directory, so they are checked before they are used as one. + if (!isSafeRelativePath(resource.path)) return + // The first segment names the library folder the file belongs to. + const separator = resource.path.indexOf('/') + if (separator <= 0) return + const name = resource.path.slice(0, separator) + let files = byLibrary.get(name) + if (!files) { + files = new Map() + byLibrary.set(name, files) + } + files.set(resource.path.slice(separator + 1), resource.content) + } + + for (const archive of (enabled.size === 0 ? [] : libraryArchives) as Array<{ + manifest?: { name?: string } + resources?: Array<{ path: string; content: string }> + }>) { + const archiveName = archive?.manifest?.name + if (typeof archiveName !== 'string' || !enabled.has(archiveName)) continue + for (const resource of archive.resources ?? []) { + addResource(resource) + } + } + + // A library project does not list itself, so its own resources arrive here + // directly when it verifies. + const own = (projectData as { ownLibraryResources?: Array<{ path: string; content: string }> }).ownLibraryResources + for (const resource of own ?? []) { + addResource(resource) + } + + return [...byLibrary].map(([name, files]) => ({ + name, + files: [...files].map(([path, content]) => ({ path, content })), + })) +} + export async function runCompilePipeline( args: RunCompilePipelineArgs, port: CompilerPlatformPort, @@ -406,6 +464,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. @@ -546,6 +605,7 @@ async function runCompilePipelineInner( programSt, md5, strucppFiles: strucppFilesMap, + libraryResources, cBlocks: { header: cBlocks.header, code: cBlocks.code }, strucppRuntimeHeaders, confs: { @@ -815,6 +875,7 @@ async function runCompilePipelineInner( const cBlocks = buildCBlocksFromPous(originalCppPous as never) const firmwareFiles = composeFirmwareBundle({ strucppFiles: strucppFilesMap, + libraryResources, cBlocks, definesH, vppConfigH, @@ -827,6 +888,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 42ccb7218..63aa33c92 100644 --- a/src/backend/shared/compile/steps/compose-firmware-bundle.ts +++ b/src/backend/shared/compile/steps/compose-firmware-bundle.ts @@ -51,6 +51,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 }> }> cBlocks: { header: string code: string | null @@ -115,6 +119,7 @@ export function buildCBlocksFromPous(originalCppPous: CppPouDataCode[]): Compose * 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 * - `examples/Baremetal/c_blocks_code.cpp` — overwritten when `cBlocks.code !== null` * - `examples/Baremetal/modules/...` — from skeleton (Arduino library helpers) @@ -125,20 +130,57 @@ export function buildCBlocksFromPous(originalCppPous: CppPouDataCode[]): Compose * - `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. */ +/** + * A stand-in `library.properties` for a folder that ships none. Without one + * arduino-cli reads the folder as a 1.0 legacy library and ignores everything + * below its root; with one it compiles `src/` recursively. `architectures=*` + * so the target never filters it out. + */ +function libraryProperties(name: string): string { + return [ + `name=${name}`, + 'version=1.0.0', + 'author=OpenPLC Editor', + 'maintainer=OpenPLC Editor ', + 'sentence=Resources shipped by an OpenPLC library', + 'paragraph=Materialised from the library archive so its C/C++ blocks compile against the sources they were built with.', + 'category=Other', + 'architectures=*', + '', + ].join('\n') +} + export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { - const { strucppFiles, cBlocks, definesH, vppConfigH, firmwareSkeleton } = input + const { strucppFiles, cBlocks, definesH, vppConfigH, firmwareSkeleton, libraryResources } = input + + const files: Record = {} + + // Each folder is written as it stands and named with its own `--library`, + // which is what makes arduino-cli compile everything under its `src/`. + // They sit beside the sketch, so a resource cannot shadow a firmware file. + for (const library of libraryResources) { + const root = `libraries/${library.name}` + for (const file of library.files) { + files[`${root}/${file.path}`] = file.content + } + if (!library.files.some((file) => file.path === 'library.properties')) { + files[`${root}/library.properties`] = libraryProperties(library.name) + } + } - // Skeleton first (every Baremetal.ino, arduino HAL, strucpp + // Skeleton next (every Baremetal.ino, arduino HAL, strucpp // runtime header, etc.). Subsequent overwrites replace specific // entries. - const files: Record = { ...firmwareSkeleton } + Object.assign(files, firmwareSkeleton) // Strucpp output lands under `src/` alongside the runtime glue // — arduino-cli's `--library src` pass picks every TU there into diff --git a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts index 5613b5c89..740a1a713 100644 --- a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts +++ b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts @@ -156,6 +156,41 @@ describe('buildArduinoCliCompileArgs', () => { expect(args.indexOf('/packages/p1am/hal/arduino/lib')).toBeLessThan(args.indexOf('--export-binaries')) }) + it('emits one --library per resource library, after the main src/', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { + sketchPath: 'examples/Baremetal/Baremetal.ino', + libraryPath: 'src', + resourceLibraryPaths: ['libraries/demo_lib', 'libraries/other_lib'], + parallel: false, + }, + ) + expect(args.filter((a) => a === '--library')).toHaveLength(3) + const firstLib = args.indexOf('--library') + expect(args.slice(firstLib, firstLib + 6)).toEqual([ + '--library', + 'src', + '--library', + 'libraries/demo_lib', + '--library', + 'libraries/other_lib', + ]) + expect(args.indexOf('libraries/other_lib')).toBeLessThan(args.indexOf('--export-binaries')) + }) + + it('emits the same argv as before when no library ships resources', () => { + // A project with no resource libraries must produce a byte-identical + // command line, so adding the option cannot disturb an existing build. + const base = { sketchPath: 'a.ino', libraryPath: 'src', parallel: false } + const withoutOption = buildArduinoCliCompileArgs({ platform: 'arduino:avr:mega' }, base) + const withEmpty = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { ...base, resourceLibraryPaths: [] }, + ) + expect(withEmpty).toEqual(withoutOption) + }) + it('emits a single --library when prebuiltLibraryPath is absent', () => { const args = buildArduinoCliCompileArgs( { platform: 'arduino:avr:mega' }, diff --git a/src/backend/shared/firmware/build-arduino-cli-args.ts b/src/backend/shared/firmware/build-arduino-cli-args.ts index f63098f8b..a98933e44 100644 --- a/src/backend/shared/firmware/build-arduino-cli-args.ts +++ b/src/backend/shared/firmware/build-arduino-cli-args.ts @@ -38,6 +38,12 @@ export interface BuildArduinoCliCompileArgsOptions { * main libraryPath as usual. */ prebuiltLibraryPath?: string + /** + * Further `--library` directories, one per library materialised from an + * enabled `.stlib`. Each carries a `library.properties`, so arduino-cli + * reads it as 1.5-format and compiles the sources under its `src/`. + */ + resourceLibraryPaths?: readonly string[] /** * Filesystem path to the avr-libstdcpp include directory. Appended * as `-I` onto `compiler.cpp.extra_flags` when the board's @@ -105,6 +111,9 @@ export function buildArduinoCliCompileArgs( if (options.prebuiltLibraryPath) { args.push('--library', options.prebuiltLibraryPath) } + for (const resourceLibrary of options.resourceLibraryPaths ?? []) { + args.push('--library', resourceLibrary) + } args.push('--export-binaries', '-b', entry.platform, options.sketchPath) if (options.trailingArgs && options.trailingArgs.length > 0) { diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index 178c37655..fb3d0b7f9 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -196,6 +196,70 @@ describe('parseLibraryManifest', () => { }) }) +// --------------------------------------------------------------------------- +// parseLibraryManifest — the `build` block (Build Settings) +// --------------------------------------------------------------------------- + +describe('parseLibraryManifest — build block', () => { + const withBuild = (build: unknown) => + parseLibraryManifest(JSON.stringify({ name: 'x', version: '1.0', namespace: 'x', build })) + + it('defaults to an Arduino target with no core when the block is absent', () => { + const res = parseLibraryManifest(VALID_MANIFEST_JSON) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'arduino' }) + }) + + it('reads the mode and the core', () => { + const res = withBuild({ verify: 'arduino', core: 'esp32:esp32' }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'arduino', core: 'esp32:esp32' }) + }) + + it('keeps a core recorded alongside a non-Arduino mode', () => { + // The dialog remembers the core while another mode is selected, so + // switching back does not lose the choice. + const res = withBuild({ verify: 'runtime', core: 'esp32:esp32' }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget).toEqual({ mode: 'runtime', core: 'esp32:esp32' }) + }) + + it('accepts every mode the dialog offers', () => { + for (const mode of ['arduino', 'runtime', 'off']) { + const res = withBuild({ verify: mode }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.manifest.verifyTarget.mode).toBe(mode) + } + }) + + it('rejects an unknown mode rather than falling back to the default', () => { + // A typo that silently verified against a different toolchain would + // report on something the author did not ask about. + const res = withBuild({ verify: 'arduno' }) + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build\.verify must be one of/) + }) + + it('rejects a non-object build block', () => { + const res = withBuild('arduino') + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build must be a JSON object/) + }) + + it('rejects an empty core', () => { + const res = withBuild({ core: '' }) + expect(res.ok).toBe(false) + if (res.ok) return + expect(res.errors[0]).toMatch(/manifest\.build\.core must be a non-empty string/) + }) +}) + // --------------------------------------------------------------------------- // stubProgramFor // --------------------------------------------------------------------------- @@ -355,6 +419,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' } as const, extra: {} as Record, } @@ -528,6 +593,7 @@ describe('libraryBuildFromTranspiledSt', () => { name: 'demo_lib', version: '1.0.0', namespace: 'demo_lib', + verifyTarget: { mode: 'arduino' }, extra: { description: 'a demo lib', displayName: 'Demo Library' }, }, { @@ -635,6 +701,109 @@ describe('libraryBuildFromTranspiledSt', () => { ) expect(res.success).toBe(true) + + // strucpp refuses an empty source list unless the caller states that the + // library's content lives outside it. + const [sources, opts] = compileStlib.mock.calls[0] + expect(sources).toEqual([]) + expect(opts).toMatchObject({ allowEmptySources: true }) + }) + + 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('refuses a C/C++ library whose name is not a valid C identifier', () => { + // The name is emitted verbatim as `__`, so a hyphen reaches + // the compiler as a subtraction. + const compileStlib = jest.fn() + __setStrucppRuntimeForTests( + makeStrucppStub({ compileStlib: compileStlib as unknown as StrucppRuntime['compileStlib'] }), + ) + + const res = libraryBuildFromTranspiledSt( + 'FUNCTION_BLOCK CppOnly\n VAR x : BOOL; END_VAR\n x := TRUE;\nEND_FUNCTION_BLOCK\n' + + 'PROGRAM main\n VAR LocalVar : INT; END_VAR\n LocalVar := 3;\nEND_PROGRAM\n', + [ + { name: 'CppOnly', kind: 'FUNCTION_BLOCK' }, + { name: STUB.STUB_PROGRAM_NAME, kind: 'PROGRAM' }, + ], + { ...manifest, name: 'demo-lib', namespace: 'demo_lib' }, + { + cppBlocks: [{ name: 'CppOnly', code: 'void setup() {}\nvoid loop() {}', variables: [] }], + }, + ) + + expect(res.success).toBe(false) + expect(res.errors[0]?.message).toMatch(/valid C identifier/) + expect(compileStlib).not.toHaveBeenCalled() + }) + + it('leaves a non-identifier name alone when the library ships no C/C++ blocks', () => { + // Such a name never reaches C, and rejecting it would refuse libraries + // that build today. + const compileStlib = jest.fn().mockReturnValue({ success: true, archive: { manifest: {}, dependencies: [] } }) + __setStrucppRuntimeForTests( + makeStrucppStub({ compileStlib: compileStlib as unknown as StrucppRuntime['compileStlib'] }), + ) + + const res = libraryBuildFromTranspiledSt( + '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', + [ + { name: 'Tank', kind: 'FUNCTION_BLOCK' }, + { name: STUB.STUB_PROGRAM_NAME, kind: 'PROGRAM' }, + ], + { ...manifest, name: 'demo-lib', namespace: 'demo_lib' }, + ) + + expect(res.success).toBe(true) + expect(compileStlib).toHaveBeenCalled() }) it('matches POU docs case-insensitively (the transpiler upper-cases identifiers)', () => { 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 4823c0a5a..cb0f2be3a 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -73,6 +73,15 @@ function makePort(): PortHarness { throwOn: {}, } harness.port = { + listProjectFiles(_projectPath: string, relPath: string) { + const prefix = `${relPath}/` + return Promise.resolve( + [...harness.files.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) + .sort(), + ) + }, async computeMd5(input: string) { // Deterministic stand-in — same input → same hash, different // inputs → different hashes. Length-prefix makes near-duplicates @@ -114,6 +123,22 @@ function makePort(): PortHarness { return harness } +/** + * The harness MD5 of what the orchestrator actually hashes: `program.st`, + * each C/C++ block's name and body, each resource, and the verify target. + */ +function verifyInputsMd5( + cppPous: Array<{ name: string; code: string }> = [], + resources: Array<{ path: string; content: string }> = [], + target: { mode: string; core?: string } = { mode: 'arduino' }, +): string { + const cppSource = cppPous.map((b) => `${b.name}\n${b.code}`).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: [], @@ -136,7 +161,7 @@ beforeEach(() => { mockPrepareXml.mockReturnValue({ projectData: projectDataEmpty(), knownPous: [], - manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', extra: {} }, + manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', verifyTarget: { mode: 'arduino' }, extra: {} }, }) mockLibraryBuild.mockReturnValue({ success: true, archive: { stub: true }, errors: [] }) }) @@ -177,7 +202,7 @@ describe('runLibraryBuildPipeline', () => { 'Starting library build...', 'Manifest OK — building "lib" v0.1.0.', 'Transpiling project to Structured Text', - 'Verifying with OpenPLC Simulator (avr-gcc)...', + 'Verifying library compile...', 'Compiling library archive...', 'Library built successfully: build/lib.stlib', ]), @@ -291,7 +316,7 @@ describe('runLibraryBuildPipeline', () => { // Pre-seed the cache. computeMd5 in the harness is deterministic // off program.st length + first char; the orchestrator's value // will match this when the same transpiler output replays. - const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` + const expectedMd5 = verifyInputsMd5() harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { events, emit } = captureEvents() @@ -310,9 +335,146 @@ describe('runLibraryBuildPipeline', () => { expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(true) }) + it('reads the resources/ tree and hands it to the build', async () => { + const harness = makePort() + harness.files.set('resources/DemoProtocol/library.properties', 'name=DemoProtocol\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '#pragma once\n') + harness.files.set('resources/DemoProtocol/src/transport/DemoSerial.cpp', '// serial\n') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + // Paths keep the author's layout, relative to `resources/`, so the first + // segment names the library folder the file belongs to. + expect(aux.resources).toEqual([ + { path: 'DemoProtocol/library.properties', content: 'name=DemoProtocol\n' }, + { path: 'DemoProtocol/src/DemoApi.h', content: '#pragma once\n' }, + { path: 'DemoProtocol/src/transport/DemoSerial.cpp', content: '// serial\n' }, + ]) + }) + + it('warns and skips a resource that is not inside a library folder', async () => { + // It belongs to no library, so there is nowhere for the consumer to + // materialise it. Reported rather than dropped in silence. + const harness = makePort() + harness.files.set('resources/stray.h', '#pragma once\n') + harness.files.set('resources/DemoProtocol/src/DemoApi.h', '// api\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/src/DemoApi.h', content: '// api\n' }]) + expect(events.some((e) => e.level === 'warning' && e.message.includes('stray.h'))).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/src/DemoApi.h', content: '#pragma once\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 C/C++ 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 = [{ name: 'SmartGate', code: 'void setup() {}\nvoid loop() {}' }] + const after = [{ name: 'SmartGate', code: 'void setup() {}\nvoid loop() { gate(); }' }] + 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(), + originalCppPous: after.map((b) => ({ ...b, variables: [] })), + } as unknown as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + 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 C/C++ block bodies are unchanged', async () => { + const harness = makePort() + const blocks = [{ name: 'SmartGate', code: 'void setup() {}\nvoid loop() {}' }] + harness.files.set( + 'build/.verify-cache-library.json', + JSON.stringify({ md5: verifyInputsMd5(blocks), success: true }), + ) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: { + ...projectDataEmpty(), + originalCppPous: blocks.map((b) => ({ ...b, variables: [] })), + } as unknown as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(true) + }) + it('cleanBuild forces a fresh verification regardless of cache', async () => { const harness = makePort() - const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` + const expectedMd5 = verifyInputsMd5() harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { emit } = captureEvents() @@ -545,6 +707,97 @@ describe('runLibraryBuildPipeline', () => { expect(result.error).toMatch(/transpile-from-json failed: transpile-from-json failed/) }) + it('hands the manifest verify target to the port', async () => { + const harness = makePort() + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { + name: 'lib', + version: '0.1.0', + namespace: 'lib', + verifyTarget: { mode: 'arduino', core: 'esp32:esp32' }, + extra: {}, + }, + }) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + expect(harness.verifyCalls[0].target).toEqual({ mode: 'arduino', core: 'esp32:esp32' }) + }) + + it('skips verification entirely, cache included, when the target is off', async () => { + const harness = makePort() + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', verifyTarget: { mode: 'off' }, extra: {} }, + }) + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + expect(harness.files.has('build/.verify-cache-library.json')).toBe(false) + // The `.stlib` still builds — verification was always advisory. + expect(result.success).toBe(true) + expect(result.verification).toBeUndefined() + expect(events.map((e) => e.message)).toEqual( + expect.arrayContaining(['Verification is off in Build Settings — skipping.']), + ) + }) + + it('re-verifies when only the target changed', async () => { + const harness = makePort() + // Cache written for the default target; the project now names a core. + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: verifyInputsMd5(), success: true })) + mockPrepareXml.mockReturnValue({ + projectData: projectDataEmpty(), + knownPous: [], + manifest: { + name: 'lib', + version: '0.1.0', + namespace: 'lib', + verifyTarget: { mode: 'arduino', core: 'esp32:esp32' }, + extra: {}, + }, + }) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + }) + it('treats a thrown verifyCompile as a failed (advisory) verification', async () => { const harness = makePort() // A non-Error throwable exercises the `String(error)` fallback in diff --git a/src/backend/shared/library/build-pipeline.ts b/src/backend/shared/library/build-pipeline.ts index 8d026e6f0..6f68bb1e5 100644 --- a/src/backend/shared/library/build-pipeline.ts +++ b/src/backend/shared/library/build-pipeline.ts @@ -37,6 +37,8 @@ import type { PLCProject, PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { checkPathId } from '@root/backend/shared/utils/path-safety' import { type KnownPou, splitProgramSt } from '@root/backend/shared/utils/PLC/split-program-st' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { parseVerifyTarget } from '@root/middleware/shared/utils/library/manifest-build-block' import { compileStlib, type CompileStlibError, type CompileStlibSource } from './compile-stlib' @@ -61,6 +63,9 @@ export interface LibraryBuildManifest { name: string version: string namespace: string + /** Toolchain the library is verified with, from the manifest's `build` + * block. Defaults to `{ mode: 'arduino' }` when the block is absent. */ + verifyTarget: LibraryVerifyTarget /** Whatever else was in the JSON. Forwarded to strucpp's * compileStlib via the spread in `composeStlibInputs`, so * upstream additions don't require an editor change. */ @@ -113,6 +118,9 @@ function parseLibraryManifest(json: string): ManifestParseResult { ) } + const verify = parseVerifyTarget(obj) + if ('errors' in verify) errors.push(...verify.errors) + if (errors.length > 0) return { ok: false, errors } return { @@ -121,6 +129,7 @@ function parseLibraryManifest(json: string): ManifestParseResult { name: obj.name as string, version: obj.version as string, namespace: obj.namespace as string, + verifyTarget: (verify as { target: LibraryVerifyTarget }).target, extra: obj, }, } @@ -324,6 +333,20 @@ export interface LibraryCppBlock { documentation?: 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 + content: string +} + export interface LibraryBuildAux { pouDocs?: Record dependencyArchives?: unknown[] @@ -333,6 +356,9 @@ export interface LibraryBuildAux { * `cppBlocks` field after compileStlib returns. See * `LibraryCppBlock` for the per-entry shape. */ cppBlocks?: LibraryCppBlock[] + /** Files from the library project's `resources/` tree. Stamped onto the + * archive's `resources` field for the consumer to materialise. */ + resources?: LibraryResource[] } /** @@ -424,10 +450,27 @@ export function libraryBuildFromTranspiledSt( } } + // `injectLibraryCppBlocks` emits each grafted POU as `__`, a C + // identifier, but `checkPathId` permits `.` and `-`. Only libraries that + // ship blocks reach C, so an ST-only library keeps any name it had. + if (hasCppBlocks && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(manifest.name)) { + return { + success: false, + errors: [ + { + message: `manifest.name must be a valid C identifier (letters, digits, underscore; cannot start with a digit) because this library ships C/C++ blocks, which are emitted as \`${manifest.name}__\`. Got: ${JSON.stringify(manifest.name)}`, + }, + ], + } + } + const compileRes = compileStlib(sources, { name: manifest.name, version: manifest.version, namespace: manifest.namespace, + // A library whose content is entirely C/C++ blocks leaves strucpp no ST + // to compile; without this it refuses with "No source files provided". + ...(hasCppBlocks ? { allowEmptySources: true } : {}), ...(aux?.dependencyArchives && aux.dependencyArchives.length > 0 ? { dependencies: aux.dependencyArchives as never } : {}), @@ -473,6 +516,7 @@ function decorateArchive(archive: unknown, manifest: LibraryBuildManifest, aux: } dependencies?: Array<{ name: string; version: string }> cppBlocks?: LibraryCppBlock[] + resources?: LibraryResource[] } if (!arch.manifest) return @@ -525,6 +569,12 @@ function decorateArchive(archive: unknown, manifest: LibraryBuildManifest, aux: ...(b.documentation && b.documentation.length > 0 ? { documentation: b.documentation } : {}), })) } + + // Resources ride through the same way. 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 })) + } } /** @@ -554,10 +604,10 @@ function inferCategory(fileName: string): string | undefined { * (Phase 8) doesn't try to recurse back into the library branch. * * Verification runs the resulting project through the standard - * ST→C++→arduino-cli pipeline against the OpenPLC Simulator - * target. Compile failures there are surfaced as warnings — the - * `.stlib` is still produced (the user may legitimately target a - * platform with more memory than the AVR simulator). + * ST→C++→arduino-cli pipeline against the manifest's verify target. + * Compile failures there are surfaced as warnings — the `.stlib` is still + * produced, because it carries source and the consumer compiles it for its + * own board. */ export function composeVerificationProject(project: PLCProject): PLCProject { const stubbed = stubProgramFor(project) diff --git a/src/backend/shared/library/compile-stlib.ts b/src/backend/shared/library/compile-stlib.ts index 6cfa01830..3607cd423 100644 --- a/src/backend/shared/library/compile-stlib.ts +++ b/src/backend/shared/library/compile-stlib.ts @@ -29,6 +29,9 @@ export interface CompileStlibOptions { noSource?: boolean /** Mark as a built-in runtime library (strucpp's bundled libs). */ builtin?: boolean + /** Compile a library that declares no ST symbols — one whose content + * is entirely C/C++ blocks the consumer compiles itself. */ + allowEmptySources?: boolean globalConstants?: Record } diff --git a/src/backend/shared/library/library-build-orchestrator.ts b/src/backend/shared/library/library-build-orchestrator.ts index f7ffa991a..7a0013d2a 100644 --- a/src/backend/shared/library/library-build-orchestrator.ts +++ b/src/backend/shared/library/library-build-orchestrator.ts @@ -22,7 +22,7 @@ * (see path-constants comment). * 3. Resolve project-enabled library archives + fail on missing * names (one place — feeds BOTH verification and strucpp). - * 4. Verification compile against the OpenPLC Simulator target + * 4. Verification compile against the manifest's verify target * via `LibraryBuildPort.verifyCompile`. MD5 cache hit short- * circuits. Cache record persisted under `build/`. * 5. Gather `pouDocs` + `cppBlocks` from the project data. @@ -38,10 +38,12 @@ import type { LibraryBuildPort } from '../../../middleware/shared/ports/library-build-port' import type { CompileLibraryResult } from '../../../middleware/shared/ports/types' import type { PLCProject, PLCProjectData } from '../types/PLC/open-plc' +import { isSafeRelativePath } from '../utils/path-safety' import { composeVerificationProject, libraryBuildFromTranspiledSt, type LibraryCppBlock, + type LibraryResource, prepareXmlForLibraryBuild, } from './build-pipeline' @@ -82,9 +84,58 @@ export interface LibraryBuildArgs { // the user-visible `.stlib` artifact and the verification cache are // written to the project tree. const VERIFY_CACHE_REL_PATH = 'build/.verify-cache-library.json' + +/** Where a library project keeps the files it ships alongside its blocks. */ +const RESOURCES_REL_PATH = 'resources' + const LIBRARY_MANIFEST_REL_PATH = 'library.json' const STLIB_OUT_DIR = 'build' +/** + * Read the library folders under `resources/`, paths intact — the consumer + * reproduces the layout and resolves each folder as a library. + * + * Reports and skips a file that is not inside a folder (it belongs to no + * library) or is not valid UTF-8 (the archive is JSON). + */ +async function readResources( + port: LibraryBuildPort, + projectPath: string, + emit: (event: LibraryBuildEvent) => void, +): Promise { + const paths = await port.listProjectFiles(projectPath, RESOURCES_REL_PATH) + const resources: LibraryResource[] = [] + for (const relPath of paths) { + if (!relPath.includes('/') || !isSafeRelativePath(relPath)) { + emit({ + message: `Skipping resource "${relPath}": it is not inside a library folder.`, + level: 'warning', + }) + continue + } + const content = await port.readBuildFile(projectPath, `${RESOURCES_REL_PATH}/${relPath}`) + if (content === null) continue + if (content.includes('\uFFFD')) { + emit({ message: `Skipping resource "${relPath}": not a text file.`, level: 'warning' }) + continue + } + resources.push({ path: relPath, content }) + } + return resources +} + +/** + * The project's C/C++ POUs, as `preprocessPous` left them. Read in two + * places — the verification cache key and the `cppBlocks` aux block — so the + * shape is narrowed once here. + */ +function readCppPous(projectData: unknown): Array<{ name: string; code: string; variables: unknown[] }> { + return ( + (projectData as { originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> }) + .originalCppPous ?? [] + ) +} + /** * Run the full library-build pipeline. Pure with respect to its * arguments — every side effect funnels through `port` or `emit`. @@ -185,21 +236,41 @@ export async function runLibraryBuildPipeline( // ------------------------------------------------------------------------- // Stage 5: verification compile // - // Hash program.st and consult the cache; cache hit short-circuits - // the slow avr-gcc compile. cleanBuild forces a fresh run. + // Hash the verified sources and consult the cache; cache hit + // short-circuits the slow compile. cleanBuild forces a fresh run. // Verification failures are advisory: they surface as warnings on // `verification.success` with the build still producing a `.stlib`. // + // The key covers the C/C++ block bodies and the resources as well as + // `program.st`. A block's body never reaches `program.st` — the emitted ST + // is a stub built from its pins — so hashing that alone replays a stale + // result after a body or a resource changes. + // // The MD5 routes through the platform port instead of `node:crypto` // so the shared module ships without a host-runtime dependency. // Editor's port wires it to Node's hash; web's port wires it to // spark-md5 — both produce byte-identical output. + // + // `build.verify: "off"` in the manifest skips the whole stage, cache + // included: a library whose C++ targets no toolchain the editor can drive + // would otherwise carry a permanent failure that reports nothing. // ------------------------------------------------------------------------- - const programStMd5 = await port.computeMd5(programSt) + const resources = await readResources(port, projectPath, emit) + const verifyTarget = manifest.verifyTarget + const cppSource = readCppPous(projectData) + .map((b) => `${b.name}\n${b.code}`) + .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${cppSource}\n${resourceSource}\n${targetSource}`) let verification: CompileLibraryResult['verification'] let usedCache = false - if (!cleanBuild) { - const cached = await readVerificationCache(port, projectPath, programStMd5) + if (verifyTarget.mode === 'off') { + emit({ message: 'Verification is off in Build Settings — skipping.', level: 'info' }) + } else if (!cleanBuild) { + const cached = await readVerificationCache(port, projectPath, verifyInputsMd5) if (cached) { verification = cached usedCache = true @@ -209,16 +280,19 @@ export async function runLibraryBuildPipeline( }) } } - if (!verification) { + if (!verification && verifyTarget.mode !== 'off') { const verifyProject = composeVerificationProject({ meta: { name: manifest.name, type: 'plc-library' }, data: verifyProjectData, }) - emit({ message: 'Verifying with OpenPLC Simulator (avr-gcc)...', level: 'info' }) + emit({ message: 'Verifying library compile...', level: 'info' }) try { verification = await port.verifyCompile({ projectPath, - verifyProjectData: verifyProject.data, + // A library project does not list itself, so its resources have to be + // handed over explicitly for its own blocks to resolve their includes. + verifyProjectData: { ...verifyProject.data, ownLibraryResources: resources } as PLCProjectData, + target: verifyTarget, emit: (message, logLevel) => { // Demote inner errors to warnings on the way out. `.stlib` // is still produced, so an error-level `[verify]` line in @@ -244,7 +318,7 @@ export async function runLibraryBuildPipeline( await port.writeBuildFile( projectPath, VERIFY_CACHE_REL_PATH, - JSON.stringify({ md5: programStMd5, ...verification }, null, 2), + JSON.stringify({ md5: verifyInputsMd5, ...verification }, null, 2), ) } catch (cacheErr) { emit({ message: `Could not write verification cache: ${formatError(cacheErr)}`, level: 'warning' }) @@ -272,10 +346,7 @@ export async function runLibraryBuildPipeline( pouDocs[name] = doc } } - const cppBlocks: LibraryCppBlock[] = ( - (projectData as { originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> }) - .originalCppPous ?? [] - ).map((b) => ({ + const cppBlocks: LibraryCppBlock[] = readCppPous(projectData).map((b) => ({ name: b.name, code: b.code, variables: b.variables, @@ -290,6 +361,7 @@ export async function runLibraryBuildPipeline( dependencyArchives: depArchives, dependencyRefs: enabledLibraryRefs, cppBlocks, + resources, }) if (!stage7.success) { for (const err of stage7.errors) { @@ -329,14 +401,14 @@ export async function runLibraryBuildPipeline( /** * Read + validate the verification cache. Returns the cached * `{ success, message }` only when the persisted MD5 matches the - * current `programSt`. Malformed cache files and missing files are + * sources being verified. Malformed cache files and missing files are * indistinguishable from a fresh build — both return null so the * caller falls through to a real verification run. */ async function readVerificationCache( port: LibraryBuildPort, projectPath: string, - programStMd5: string, + verifyInputsMd5: string, ): Promise<{ success: boolean; message?: string } | null> { let raw: string | null try { @@ -347,7 +419,7 @@ async function readVerificationCache( if (raw === null) return null try { const parsed = JSON.parse(raw) as { md5?: string; success?: boolean; message?: string } - if (parsed?.md5 === programStMd5 && typeof parsed.success === 'boolean') { + if (parsed?.md5 === verifyInputsMd5 && typeof parsed.success === 'boolean') { return { success: parsed.success, message: parsed.message } } } catch { diff --git a/src/backend/shared/project/__tests__/create-project-files.test.ts b/src/backend/shared/project/__tests__/create-project-files.test.ts index b5670f2a6..ee7737fc6 100644 --- a/src/backend/shared/project/__tests__/create-project-files.test.ts +++ b/src/backend/shared/project/__tests__/create-project-files.test.ts @@ -99,6 +99,10 @@ describe('buildProjectFileContent', () => { expect(built.libraryManifest).toBeUndefined() }) + it('does not emit a resources README — a program has no resources', () => { + expect(built.libraryResourcesReadme).toBeUndefined() + }) + describe('default POU body per language', () => { it('seeds a ladder rung container for LD projects', () => { const ld = buildProjectFileContent({ name: 'P', type: 'plc-project', language: 'ld', time: 'T#20ms' }) @@ -143,6 +147,14 @@ describe('buildProjectFileContent', () => { expect(built.project.meta.type).toBe('plc-library') }) + it('emits a resources README describing the folder-per-library layout', () => { + // `resources/` is created empty, and an empty directory does not survive + // a commit, so the README is what carries the convention to the author. + expect(built.libraryResourcesReadme).toBeDefined() + expect(built.libraryResourcesReadme).toContain('library.properties') + expect(built.libraryResourcesReadme).toContain('src/') + }) + it('emits a library manifest with snake_case namespace auto-fill', () => { expect(built.libraryManifest).toBeDefined() const manifest = JSON.parse(built.libraryManifest as string) as Record diff --git a/src/backend/shared/project/create-project-files.ts b/src/backend/shared/project/create-project-files.ts index d84155ecc..058dff058 100644 --- a/src/backend/shared/project/create-project-files.ts +++ b/src/backend/shared/project/create-project-files.ts @@ -35,6 +35,42 @@ export interface CreateProjectFileInput { * to persist. `pous` carries the editor-flat shape (one PLCPou per * file) so callers don't re-derive it. */ +/** + * Written to `resources/README.md` when a library project is created. The + * directory is otherwise empty, and an empty directory does not survive a + * commit, so the file is what carries the convention to the author. + */ +const LIBRARY_RESOURCES_README = `# Resources + +Put the C/C++ libraries your blocks need in here, one folder each: + +\`\`\` +resources/ + MyLibrary/ + library.properties + src/ + MyLibrary.h + MyLibrary.cpp +\`\`\` + +That is the ordinary Arduino library layout, so in most cases you can copy a +library folder in as it stands. Build Settings, under Manifest in the project +tree, adds one for you and lists what is here. + +Everything in here is packaged into the \`.stlib\` when you build. A project +that installs your library gets these sources unpacked into its own build and +compiled for its own target, so there is nothing for the user to install +separately and the sources can never fall out of step with your blocks. + +## Notes + +- Sources may nest as deeply as you like under \`src/\`. +- A folder without a \`library.properties\` still works; one is generated. +- A file sitting loose in \`resources/\`, outside any folder, belongs to no + library and is skipped with a warning. +- Only text files are carried. Pre-compiled binaries are not. +` + export interface CreateProjectFileContent { project: PLCProject pous: PLCPou[] @@ -44,6 +80,9 @@ export interface CreateProjectFileContent { * that the editor writes alongside `project.json`. Pre-filled with * snake_case namespace, version `0.1.0`, empty symbol arrays. */ libraryManifest?: string + /** README written into a library project's `resources/` directory, + * explaining what the folder is for. Library projects only. */ + libraryResourcesReadme?: string } /** @@ -210,6 +249,11 @@ export function buildProjectFileContent(input: CreateProjectFileInput): CreatePr pous, deviceConfiguration, devicePinMapping, - ...(isLibrary ? { libraryManifest: buildLibraryManifestTemplate(input.name) } : {}), + ...(isLibrary + ? { + libraryManifest: buildLibraryManifestTemplate(input.name), + libraryResourcesReadme: LIBRARY_RESOURCES_README, + } + : {}), } } diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index ba3382fdb..68dd604c7 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 }, @@ -27,6 +27,24 @@ const makeArrayVar = (name: string, cls: 'input' | 'output', baseType: string, d }) describe('generateCBlocksCode', () => { + it('carries VAR_IN_OUT pins into the struct, the defines and the undefs', () => { + // strucpp stores an FB's in/out parameter as a by-value member of the same + // shape as an input, so it needs no separate treatment — only including. + const variables: PLCVariable[] = [ + makeScalarVar('SP', 'input', 'INT'), + makeScalarVar('PV', 'output', 'INT'), + makeScalarVar('LEVEL', 'inOut', 'INT'), + ] + const result = generateCBlocksCode([{ name: 'Tank', code: 'void setup() { }\nvoid loop() { }', variables }]) + + expect(result).toContain(' strucpp::IEC_INT *LEVEL;') + expect(result).toContain('#define LEVEL (*(vars->LEVEL))') + expect(result).toContain('#undef LEVEL') + // Inputs, then outputs, then in-outs — strucpp's own field order. + expect(result.indexOf('*SP;')).toBeLessThan(result.indexOf('*PV;')) + expect(result.indexOf('*PV;')).toBeLessThan(result.indexOf('*LEVEL;')) + }) + it('returns empty string for empty pous array', () => { const result = generateCBlocksCode([]) expect(result).toBe('') diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksHeader.test.ts index 3f556ee5f..49254d39a 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 }, @@ -27,6 +27,17 @@ const makeArrayVar = (name: string, cls: 'input' | 'output', baseType: string, d }) describe('generateCBlocksHeader', () => { + it('declares VAR_IN_OUT pins, matching the struct generateCBlocksCode emits', () => { + const variables: PLCVariable[] = [ + makeScalarVar('SP', 'input', 'INT'), + makeScalarVar('PV', 'output', 'INT'), + makeScalarVar('LEVEL', 'inOut', 'INT'), + ] + const result = generateCBlocksHeader([{ name: 'Tank', variables }]) + + expect(result).toContain(' strucpp::IEC_INT *LEVEL;') + expect(result.indexOf('*PV;')).toBeLessThan(result.indexOf('*LEVEL;')) + }) it('generates header with guard macros for empty pous array', () => { const result = generateCBlocksHeader([]) expect(result).toContain('#ifndef C_BLOCKS_H') diff --git a/src/backend/shared/utils/cpp/generateCBlocksCode.ts b/src/backend/shared/utils/cpp/generateCBlocksCode.ts index e0794ab84..e37cb5c2c 100644 --- a/src/backend/shared/utils/cpp/generateCBlocksCode.ts +++ b/src/backend/shared/utils/cpp/generateCBlocksCode.ts @@ -112,17 +112,19 @@ const processUserCode = (pou: CppPouData): string => { const setupFunctionName = `${pou.name.toLowerCase()}_setup` const loopFunctionName = `${pou.name.toLowerCase()}_loop` - const inputVariables = pou.variables.filter((v) => v.class === 'input') - const outputVariables = pou.variables.filter((v) => v.class === 'output') + // Strucpp stores an FB's in/out parameter as a by-value member, the same + // shape as an input, so it needs no separate handling here. Order mirrors + // strucpp's own field order. + const pinVariables = [ + ...pou.variables.filter((v) => v.class === 'input'), + ...pou.variables.filter((v) => v.class === 'output'), + ...pou.variables.filter((v) => v.class === 'inOut'), + ] let processedCode = `//definition of external blocks - ${pou.name.toUpperCase()}\n` processedCode += `typedef struct {\n` - inputVariables.forEach((variable) => { - processedCode += generateStructMember(variable) - }) - - outputVariables.forEach((variable) => { + pinVariables.forEach((variable) => { processedCode += generateStructMember(variable) }) @@ -131,11 +133,7 @@ const processUserCode = (pou: CppPouData): string => { processedCode += `extern "C" void ${setupFunctionName}(${structName} *vars);\n` processedCode += `extern "C" void ${loopFunctionName}(${structName} *vars);\n\n` - inputVariables.forEach((variable) => { - processedCode += generateDefine(variable) - }) - - outputVariables.forEach((variable) => { + pinVariables.forEach((variable) => { processedCode += generateDefine(variable) }) @@ -153,10 +151,7 @@ const processUserCode = (pou: CppPouData): string => { processedCode += modifiedUserCode processedCode += '\n' - inputVariables.forEach((variable) => { - processedCode += generateUndef(variable) - }) - outputVariables.forEach((variable) => { + pinVariables.forEach((variable) => { processedCode += generateUndef(variable) }) processedCode += '\n' diff --git a/src/backend/shared/utils/cpp/generateCBlocksHeader.ts b/src/backend/shared/utils/cpp/generateCBlocksHeader.ts index f5ec2ed50..3a700ff51 100644 --- a/src/backend/shared/utils/cpp/generateCBlocksHeader.ts +++ b/src/backend/shared/utils/cpp/generateCBlocksHeader.ts @@ -25,17 +25,18 @@ const generateCBlocksHeader = (cppPous: CppPouData[]): string => { const setupFunctionName = `${pou.name.toLowerCase()}_setup` const loopFunctionName = `${pou.name.toLowerCase()}_loop` - const inputVariables = pou.variables.filter((v) => v.class === 'input') - const outputVariables = pou.variables.filter((v) => v.class === 'output') + // Same set and order as `generateCBlocksCode` — the two emit the same + // struct and must not diverge. + const pinVariables = [ + ...pou.variables.filter((v) => v.class === 'input'), + ...pou.variables.filter((v) => v.class === 'output'), + ...pou.variables.filter((v) => v.class === 'inOut'), + ] headerContent += `//definition of external blocks - ${pou.name.toUpperCase()}\n` headerContent += `typedef struct {\n` - inputVariables.forEach((variable) => { - headerContent += generateStructMember(variable) - }) - - outputVariables.forEach((variable) => { + pinVariables.forEach((variable) => { headerContent += generateStructMember(variable) }) 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/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index 9bda126c6..2146ef3ad 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -59,6 +59,7 @@ const TabIcons: Record = { 'ethercat-device': , 'library-manager': , 'library-manifest': , + 'build-settings': , 'user-management': , 'diff-viewer': , } @@ -92,6 +93,7 @@ const Tab = (props: ITabProps) => { | 'ethercat-device' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'diff-viewer' = 'il' @@ -132,6 +134,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'library-manifest') { languageOrDerivation = 'library-manifest' } + if (fileDerivation?.type === 'build-settings') { + languageOrDerivation = 'build-settings' + } if (fileDerivation?.type === 'user-management') { languageOrDerivation = 'user-management' } diff --git a/src/frontend/components/_features/[workspace]/build-options/index.tsx b/src/frontend/components/_features/[workspace]/build-options/index.tsx index 670191f51..b69966cc9 100644 --- a/src/frontend/components/_features/[workspace]/build-options/index.tsx +++ b/src/frontend/components/_features/[workspace]/build-options/index.tsx @@ -138,7 +138,7 @@ export const BuildOptionsPopover = ({ /> choose('clean-upload')} diff --git a/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx b/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx new file mode 100644 index 000000000..60133feae --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/index.tsx @@ -0,0 +1,143 @@ +/** + * Build Settings screen — a Library Project's verify target and the library + * folders it ships in `resources/`. + * + * Two tabs, same shape as the Library Manager (which is itself the EtherCAT + * editor's tab structure over the package manager's card layout): + * + * - **Verify Target** — which toolchain the library is checked with. + * Stored in `library.json`'s `build` block, because a library project has + * `hasDevices: false` and so has no device screen to hang it on. Edits go + * to the same store field the Manifest tab is bound to, so the two stay + * in step and the change saves with the project. + * + * - **Resources** — the library folders packaged into the `.stlib`. These + * are files on disk, so add and remove take effect as they are made — the + * same way the Library Manager installs an archive. + * + * Named Build Settings rather than Build Options because arduino-cli already + * owns "build options" (`--build-property`, `build.options.json`), as does + * the editor's own build-options popover. + */ + +import * as Tabs from '@radix-ui/react-tabs' +import { useOpenPLCStore } from '@root/frontend/store' +import { LIBRARY_MANIFEST_TAB_NAME } from '@root/frontend/store/slices/tabs/utils' +import { cn } from '@root/frontend/utils/cn' +import type { LibraryVerifyTarget } from '@root/middleware/shared/ports/library-build-port' +import { + DEFAULT_VERIFY_TARGET, + parseVerifyTarget, + withVerifyTarget, +} from '@root/middleware/shared/utils/library/manifest-build-block' +import { useMemo, useState } from 'react' + +import { ResourcesTab } from './resources-tab' +import { VerifyTargetTab } from './verify-target-tab' + +type SettingsTab = 'verify' | 'resources' + +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +const BuildSettingsEditor = () => { + const [activeTab, setActiveTab] = useState('verify') + + const manifestContent = useOpenPLCStore((s) => s.project.data.libraryManifest ?? '') + const updateLibraryManifest = useOpenPLCStore((s) => s.projectActions.updateLibraryManifest) + const handleFileAndWorkspaceSavedState = useOpenPLCStore( + (s) => s.sharedWorkspaceActions.handleFileAndWorkspaceSavedState, + ) + const addFile = useOpenPLCStore((s) => s.fileActions.addFile) + + /** + * Derived from the manifest on every render rather than held in state, so + * the screen always shows what the Manifest tab holds right now — including + * a hand-edited `build` block, and including a half-typed one. + */ + const parsed = useMemo((): { target: LibraryVerifyTarget } | { error: string } => { + let raw: unknown + try { + raw = JSON.parse(manifestContent) + } catch { + return { error: 'library.json is not valid JSON. Fix it on the Manifest tab first.' } + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { error: 'library.json must be a JSON object.' } + } + const result = parseVerifyTarget(raw as Record) + if ('errors' in result) return { error: result.errors[0] } + return { target: result.target } + }, [manifestContent]) + + const manifestError = 'error' in parsed ? parsed.error : null + const target = 'target' in parsed ? parsed.target : DEFAULT_VERIFY_TARGET + + const handleTargetChange = (next: LibraryVerifyTarget) => { + const updated = withVerifyTarget(manifestContent, next) + if (updated === null) return + // Nothing is saved under this tab's own name: the setting lives in + // `library.json`, so the Manifest entry is what goes dirty. That entry is + // registered when the Manifest tab mounts, and this screen can be opened + // without ever opening it — `addFile` is a no-op when it exists. + addFile({ + name: LIBRARY_MANIFEST_TAB_NAME, + type: 'library-manifest', + filePath: 'library.json', + cleanState: manifestContent, + }) + handleFileAndWorkspaceSavedState(LIBRARY_MANIFEST_TAB_NAME) + updateLibraryManifest(updated) + } + + return ( +
+
+

Build Settings

+

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

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

+ {title} +

+ {subtitle &&

{subtitle}

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

{sentence}

+

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

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

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ) +} + +/** Scrolling list body. Rows carry `shrink-0`: a flex column shrinks its + * children by default, so a long list collapses each row below its own + * height instead of scrolling. */ +function ListBody({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +/** + * One selectable row. The radio mark carries the selection rather than a + * background tint alone — a tint at the contrast the rest of the editor uses + * is not readable enough to answer "which one is set?" at a glance. + * + * A disabled row does not dim itself: its card is already dimmed whenever its + * rows are disabled, and nested opacity multiplies, which would leave the row + * at a fifth of full contrast rather than half. + */ +function OptionRow({ + label, + description, + selected, + disabled, + onSelect, +}: { + label: string + description: string + selected: boolean + disabled: boolean + onSelect: () => void +}) { + return ( + + ) +} + +export { VerifyTargetTab } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 1b17f0b7e..5b1621a80 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -11,6 +11,7 @@ import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' import { useDeviceLicense } from '../../../../../../hooks/use-device-license' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' +import { openPackageManagerTab } from '../../../../../../services/open-package-manager-tab' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' @@ -286,21 +287,7 @@ const Board = memo(function () { const handleSetDeviceBoard = useCallback( (board: string) => { if (board === '__install_additional_boards__') { - const { tabsActions, editorActions } = useOpenPLCStore.getState() - const tab = { - name: 'Package Manager', - path: '/package-manager', - elementType: { type: 'package-manager' as const }, - } - tabsActions.updateTabs(tab) - const existing = editorActions.getEditorFromEditors(tab.name) - if (!existing) { - const model = { type: 'plc-package-manager' as const, meta: { name: 'Package Manager' } } - editorActions.addModel(model) - editorActions.setEditor(model) - } else { - editorActions.setEditor(existing) - } + openPackageManagerTab() return } diff --git a/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx b/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx index 40b1d01e0..698cad340 100644 --- a/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/library-manager/project-libraries-tab.tsx @@ -179,6 +179,9 @@ function SearchBar({ value, onChange }: { value: string; onChange: (v: string) = ) } +/** Scrolling list body. Rows carry `shrink-0`: a flex column shrinks its + * children by default, so a long list collapses each row below its own + * height instead of scrolling. */ function ListBody({ children }: { children: React.ReactNode }) { return
{children}
} @@ -203,7 +206,7 @@ function LibraryRow({ actionTitle?: string }) { return ( -
+
{lib.displayName ?? lib.name} diff --git a/src/frontend/components/_molecules/breadcrumbs/index.tsx b/src/frontend/components/_molecules/breadcrumbs/index.tsx index f2c6492aa..ae86cb2e1 100644 --- a/src/frontend/components/_molecules/breadcrumbs/index.tsx +++ b/src/frontend/components/_molecules/breadcrumbs/index.tsx @@ -156,6 +156,20 @@ const Breadcrumbs = () => { ) } + // Build Settings — sits beside the manifest, and is stored in it. + if (editor.type === 'plc-build-settings') { + return ( +
    +
  1. + +
  2. +
  3. + +
  4. +
+ ) + } + // EtherCAT slave device breadcrumbs if (editor.type === 'plc-ethercat-device') { return ( diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 47e42d8eb..a9b7ed7dd 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -469,6 +469,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'ethercatDevice' | 'softMotionDrive' | 'libraryManifest' + | 'buildSettings' | 'userManagement' leafType: WorkspaceProjectTreeLeafType label?: string @@ -510,6 +511,9 @@ const LeafSources = { // render the same glyph — the manifest is the user's entry point // into a library project, so it earns a dedicated mark. libraryManifest: { LeafIcon: LibraryManifestIcon }, + // Build Settings shares the device-configuration gear: both are the + // settings screen for how the project is built. + buildSettings: { LeafIcon: ConfigIcon }, userManagement: { LeafIcon: UsersIcon }, } const ProjectTreeLeaf = ({ @@ -864,7 +868,10 @@ const ProjectTreeLeaf = ({
)} - {leafLang === 'devPin' || leafLang === 'devConfig' || leafLang === 'userManagement' ? null : ( + {leafLang === 'devPin' || + leafLang === 'devConfig' || + leafLang === 'buildSettings' || + leafLang === 'userManagement' ? null : ( { 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/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index aa121ed31..b273fb03e 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -16,6 +16,7 @@ import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' import { BranchStatusBar } from '../components/_features/[workspace]/branches' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' +import { BuildSettingsEditor } from '../components/_features/[workspace]/editor/build-settings' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' import { EtherCATDeviceEditor, EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' @@ -57,6 +58,7 @@ import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monit import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' +import { openPackageManagerTab } from '../services/open-package-manager-tab' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' import { buildGlobalCompositeKey, GLOBAL_CONFIG_NAME } from '../utils/debug-variable-finder' @@ -410,23 +412,7 @@ const WorkspaceScreen = () => { useEffect(() => { if (!packagesPort) return - const unsubOpen = packagesPort.onOpenManager(() => { - const { tabsActions, editorActions } = useOpenPLCStore.getState() - const tab = { - name: 'Package Manager', - path: '/package-manager', - elementType: { type: 'package-manager' as const }, - } - tabsActions.updateTabs(tab) - const existing = editorActions.getEditorFromEditors(tab.name) - if (!existing) { - const model = { type: 'plc-package-manager' as const, meta: { name: 'Package Manager' } } - editorActions.addModel(model) - editorActions.setEditor(model) - } else { - editorActions.setEditor(existing) - } - }) + const unsubOpen = packagesPort.onOpenManager(() => openPackageManagerTab()) const unsubBoards = packagesPort.onBoardsUpdated(() => { void device.getAvailableBoards().then((boardsMap) => { @@ -589,6 +575,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-library-manager' && } {editor['type'] === 'plc-user-management' && } {editor['type'] === 'plc-library-manifest' && } + {editor['type'] === 'plc-build-settings' && } {editor['type'] === 'diff-viewer' && } {/* EtherCAT device editors — multi-instance (one tab diff --git a/src/frontend/services/open-package-manager-tab.ts b/src/frontend/services/open-package-manager-tab.ts new file mode 100644 index 000000000..d189f23e7 --- /dev/null +++ b/src/frontend/services/open-package-manager-tab.ts @@ -0,0 +1,29 @@ +import { useOpenPLCStore } from '../store' + +/** Tab name, path and model the Package Manager screen is registered under. */ +const PACKAGE_MANAGER_TAB_NAME = 'Package Manager' + +/** + * Open the Package Manager, or focus it when it is already open. + * + * Reached from three places — the device board dropdown, the library Build + * Settings core dropdown, and the main-process "open manager" event — so the + * tab/model registration lives here rather than being repeated at each. + */ +export function openPackageManagerTab(): void { + const { tabsActions, editorActions } = useOpenPLCStore.getState() + const tab = { + name: PACKAGE_MANAGER_TAB_NAME, + path: '/package-manager', + elementType: { type: 'package-manager' as const }, + } + tabsActions.updateTabs(tab) + const existing = editorActions.getEditorFromEditors(tab.name) + if (existing) { + editorActions.setEditor(existing) + return + } + const model = { type: 'plc-package-manager' as const, meta: { name: PACKAGE_MANAGER_TAB_NAME } } + editorActions.addModel(model) + editorActions.setEditor(model) +} diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 2c5bc5511..e03f24ae3 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -219,6 +219,14 @@ export type EditorModel = EditorModelBase & name: string } } + | { + /** Build Settings for a Library Project — the verify target and + * the `resources/` folders. Library projects only. */ + type: 'plc-build-settings' + meta: { + name: string + } + } | { type: 'plc-ethercat-device' meta: { diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index b3f620942..b27f8ed16 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -19,6 +19,7 @@ export type TabsProps = { | { type: 'package-manager' } | { type: 'library-manager' } | { type: 'library-manifest' } + | { type: 'build-settings' } | { type: 'user-management' } | { type: 'ethercat-device'; busName: string; deviceId: string } | { type: 'diff-viewer'; filePath: string } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index bf5b8f7b0..31efbc7ae 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -144,6 +144,16 @@ const CreateLibraryManifestEditor = (name = LIBRARY_MANIFEST_TAB_NAME): EditorMo meta: { name }, }) +/** Canonical tab name + factory for the Library Project's Build Settings. + * Nothing is saved under this name — the verify target lives in + * `library.json`, so the Manifest file entry carries the dirty flag. */ +const BUILD_SETTINGS_TAB_NAME = 'Build Settings' + +const CreateBuildSettingsEditor = (name = BUILD_SETTINGS_TAB_NAME): EditorModel => ({ + type: 'plc-build-settings', + meta: { name }, +}) + /** Read-only source-control diff tab. The tab `name` doubles as the unique * editor key, so it must not collide with the editable POU tab of the same * POU — callers pass a `Diff: ` style name. `filePath` is the @@ -203,6 +213,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateLibraryManagerEditor(name) case 'library-manifest': return CreateLibraryManifestEditor(name) + case 'build-settings': + return CreateBuildSettingsEditor(name) case 'user-management': return CreateUserManagementEditor(name) case 'diff-viewer': @@ -211,6 +223,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { } export { + BUILD_SETTINGS_TAB_NAME, + CreateBuildSettingsEditor, CreateDeviceEditor, CreateDiffViewerEditor, CreateEditorModelObject, diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 6b367affb..73544a626 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -39,6 +39,7 @@ export type WorkspaceProjectTreeLeafType = | 'package-manager' | 'library-manager' | 'library-manifest' + | 'build-settings' | 'user-management' | 'ethercat-device' | null diff --git a/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts index 4405052c0..ff86518e3 100644 --- a/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts +++ b/src/frontend/utils/PLC/__tests__/pou-text-parser.test.ts @@ -91,6 +91,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/pou-text-parser.ts b/src/frontend/utils/PLC/pou-text-parser.ts index 6a0c099aa..287570f76 100644 --- a/src/frontend/utils/PLC/pou-text-parser.ts +++ b/src/frontend/utils/PLC/pou-text-parser.ts @@ -8,16 +8,20 @@ import { getLanguageFromExtension } from './pou-file-extensions' * @returns Object with documentation and remaining content */ const extractDocumentation = (content: string): { documentation: string; remainingContent: string } => { - const docMatch = content.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) - if (docMatch) { - return { - documentation: docMatch[1].trim(), - remainingContent: content.slice(docMatch[0].length), - } + // A comment is legal wherever whitespace is, so a header may be written as + // several consecutive blocks. Taking only the first leaves the rest in + // front of the declaration, which the declaration regex then fails to match. + const blocks: string[] = [] + let remainingContent = content + for (;;) { + const docMatch = remainingContent.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) + if (!docMatch) break + blocks.push(docMatch[1].trim()) + remainingContent = remainingContent.slice(docMatch[0].length) } return { - documentation: '', - remainingContent: content, + documentation: blocks.join('\n\n'), + remainingContent, } } diff --git a/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts b/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts index c0d7d3c68..f03c55cd8 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 }, @@ -27,6 +27,22 @@ const makeArrayVar = (name: string, cls: 'input' | 'output', baseType: string, d }) describe('generateSTCode (cpp)', () => { + it('assigns a pointer for VAR_IN_OUT pins', () => { + // The FB's in/out parameter is a class member like any other pin, so its + // address goes into the struct the same way. + const result = generateSTCode({ + pouName: 'Tank', + allVariables: [ + makeScalarVar('SP', 'input', 'INT'), + makeScalarVar('PV', 'output', 'INT'), + makeScalarVar('LEVEL', 'inOut', 'INT'), + ], + }) + + expect(result).toContain('vars.LEVEL = &LEVEL;') + expect(result.indexOf('vars.PV')).toBeLessThan(result.indexOf('vars.LEVEL')) + }) + it('generates ST stub with strucpp-style direct pointer assignment for scalars', () => { const result = generateSTCode({ pouName: 'MyBlock', diff --git a/src/frontend/utils/cpp/generateSTCode.ts b/src/frontend/utils/cpp/generateSTCode.ts index 9d5a200ba..3c210c090 100644 --- a/src/frontend/utils/cpp/generateSTCode.ts +++ b/src/frontend/utils/cpp/generateSTCode.ts @@ -34,16 +34,21 @@ const generateVariableAssignment = (variable: PLCVariable): string => { const generateSTCode = (params: STCodeGenerationParams): string => { const { pouName, allVariables } = params - const inputVariables = allVariables.filter((v) => v.class === 'input') - const outputVariables = allVariables.filter((v) => v.class === 'output') + // Same set and order as the struct `generateCBlocksCode` emits. An FB's + // in/out parameter is a by-value member like an input or an output, so + // taking its address is type-compatible with the struct field. + const pinVariables = [ + ...allVariables.filter((v) => v.class === 'input'), + ...allVariables.filter((v) => v.class === 'output'), + ...allVariables.filter((v) => v.class === 'inOut'), + ] const structName = `${pouName.toUpperCase()}_VARS` const setupFunctionName = `${pouName.toLowerCase()}_setup` const loopFunctionName = `${pouName.toLowerCase()}_loop` let variableAssignments = '' - for (const variable of inputVariables) variableAssignments += generateVariableAssignment(variable) - for (const variable of outputVariables) variableAssignments += generateVariableAssignment(variable) + for (const variable of pinVariables) variableAssignments += generateVariableAssignment(variable) // Header `{external}` block: declare the user-visible struct, fill // the pointer fields. STruC++ emits this body verbatim into the diff --git a/src/main/main.ts b/src/main/main.ts index 5ae531b3e..759b62d8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -174,6 +174,18 @@ const createMainWindow = async () => { // Load the Url or index.html file; void mainWindow.loadURL(resolveHtmlPath('index.html')) + // `npm run dev` starts Electron and webpack-dev-server in parallel. On a + // slower machine Electron wins the race, gets ERR_CONNECTION_REFUSED, and + // never retries — the splash closes onto a blank window. Retry until the + // dev server answers. ERR_ABORTED (-3) is a superseded navigation, not a + // failure, and retrying it would fight the navigation that replaced it. + if (isDebug) { + mainWindow.webContents.on('did-fail-load', (_event, errorCode) => { + if (errorCode === -3) return + setTimeout(() => void mainWindow?.loadURL(resolveHtmlPath('index.html')), 500) + }) + } + // Save window bounds on resize, close, and move events const saveBounds = () => { store.set('window.bounds', mainWindow?.getBounds()) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 291add105..3426af4b4 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -69,6 +69,11 @@ import { import { PackageManagerModule } from '../../../backend/editor/package-manager' import { RuntimeApiClient } from '../../../backend/editor/runtime/runtime-api-client' import { logger } from '../../../backend/editor/services' +import { + addLibraryResource, + listLibraryResources, + removeLibraryResource, +} from '../../../backend/editor/services/library-resources-service' import { getOpenProjectPath, getPlcopenExportSavePath, @@ -707,6 +712,11 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('file:watch-stop', this.handleFileWatchStop) this.registerHandle('file:watch-stop-all', this.handleFileWatchStopAll) this.registerHandle('file:read-content', this.handleFileReadContent) + + // ===================== LIBRARY RESOURCES ===================== + this.registerHandle('library-resources:list', this.handleLibraryResourcesList) + this.registerHandle('library-resources:add', this.handleLibraryResourcesAdd) + this.registerHandle('library-resources:remove', this.handleLibraryResourcesRemove) } // ===================== HANDLER METHODS ===================== @@ -2507,6 +2517,47 @@ class MainProcessBridge implements MainIpcModule { }) } + // ===================== LIBRARY RESOURCES HANDLERS ===================== + // + // A library project's `resources/` directory, managed from the Build + // Settings dialog. Every path is derived from the open project rather + // than taken from the renderer, so there is nothing here for a compromised + // renderer to point somewhere else — the one renderer-supplied string is a + // folder name, which the service checks as a path component. + + handleLibraryResourcesList = async () => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + try { + return { success: true, folders: await listLibraryResources(this.currentProjectPath) } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + handleLibraryResourcesAdd = async () => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + if (!this.mainWindow) return { success: false, error: 'No main window' } + const picked = await dialog.showOpenDialog(this.mainWindow, { + title: 'Add library folder to resources', + properties: ['openDirectory'], + }) + if (picked.canceled || picked.filePaths.length === 0) return { success: false, canceled: true } + try { + return await addLibraryResource(this.currentProjectPath, picked.filePaths[0]) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + handleLibraryResourcesRemove = async (_event: IpcMainInvokeEvent, folderName: string) => { + if (!this.currentProjectPath) return { success: false, error: 'No project is open' } + try { + return await removeLibraryResource(this.currentProjectPath, folderName) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + // ===================== EVENT HANDLERS ===================== mainIpcEventHandlers = { handleUpdateTheme: (_event: unknown, theme?: 'light' | 'dark' | 'nineties') => { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index b5b8e6d5f..f15d5f744 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -702,6 +702,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/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 5af2c5d88..819c56fc2 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -350,6 +350,18 @@ export function createEditorProjectAdapter(): ProjectPort { }) }, + async listLibraryResources() { + return window.bridge.libraryResourcesList() + }, + + async addLibraryResource() { + return window.bridge.libraryResourcesAdd() + }, + + async removeLibraryResource(folderName: string) { + return window.bridge.libraryResourcesRemove(folderName) + }, + async pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> { const response = await window.bridge.pickPlcopenImportFile() if (!response.success) { diff --git a/src/middleware/shared/ports/index.ts b/src/middleware/shared/ports/index.ts index cbad8507f..f42471946 100644 --- a/src/middleware/shared/ports/index.ts +++ b/src/middleware/shared/ports/index.ts @@ -60,7 +60,7 @@ export type { DevicePort } from './device-port' export type { NavigationPort, NavigationSearch } from './navigation-port' export { buildNavigationUrl } from './navigation-port' export type { OrchestratorPort } from './orchestrator-port' -export type { ProjectPort } from './project-port' +export type { LibraryResourceFolder, ProjectPort } from './project-port' export type { RuntimePort } from './runtime-port' export type { SimulatorPort } from './simulator-port' export type { StlibSource, StlibSourcePort } from './stlib-source-port' diff --git a/src/middleware/shared/ports/library-build-port.ts b/src/middleware/shared/ports/library-build-port.ts index 3134a05e5..14af822ce 100644 --- a/src/middleware/shared/ports/library-build-port.ts +++ b/src/middleware/shared/ports/library-build-port.ts @@ -31,8 +31,8 @@ import type { TranspileToStArgs, TranspileToStResult } from './compiler-platform-port' /** - * Outcome of an attempted verification compile against the OpenPLC - * Simulator board. Verification is advisory: a `success: false` + * Outcome of an attempted verification compile. Verification is + * advisory: a `success: false` * surfaces as a warning on the build result, never as a fatal error * (the `.stlib` still ships). See `runLibraryBuildPipeline` for the * cache + skip-on-md5-match flow that wraps this. @@ -61,6 +61,26 @@ export interface LibraryArchiveLookupArgs { projectLibraryRefs: ReadonlyArray<{ name: string; version: string }> } +/** + * Which toolchain a library is verified with. Authored in `library.json`'s + * `build` block through the Build Settings dialog; the orchestrator reads it + * and hands it to the port, which owns the catalogue lookup that turns a core + * into something its platform can compile. + */ +export interface LibraryVerifyTarget { + /** + * `arduino` compiles the library's C++ with the Arduino toolchain for + * `core` — the only mode that checks the C++. `runtime` transpiles and + * composes a Runtime v4 bundle, which checks the ST and the bundle but not + * the C++, because a runtime compiles its own upload. `off` skips + * verification. + */ + mode: 'arduino' | 'runtime' | 'off' + /** Arduino core to compile against (`esp32:esp32`). Absent leaves the + * choice to the port. */ + core?: string +} + export interface VerifyCompileArgs { /** Project root path on the host platform. Same value the build * orchestrator received; the port impl knows how to interpret it. */ @@ -79,6 +99,9 @@ export interface VerifyCompileArgs { * before threading into the IPC envelope). */ verifyProjectData: unknown + /** Toolchain to verify against, resolved from the manifest by the + * orchestrator. The port maps it onto its own board catalogue. */ + target: LibraryVerifyTarget /** Caller log callback. Every line the inner compile emits is * forwarded here; the orchestrator prefixes them with `[verify]` * before forwarding to its own caller. */ @@ -129,6 +152,13 @@ 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 + /** * Recursively remove a project-relative subtree. No-op when the * subtree doesn't exist. Implementations MUST scope deletion to @@ -153,13 +183,15 @@ export interface LibraryBuildPort { loadLibraryArchives(args: LibraryArchiveLookupArgs): Promise /** - * Run a verification compile of `verifyProjectData` against the - * OpenPLC Simulator board. Both platform impls internally drive - * the shared `runCompilePipeline` — the only thing they own is the - * platform-specific arg assembly (board entry, hals data, firmware - * skeleton) and the transport. Failures are advisory: the - * orchestrator surfaces them as a warning on the build result, - * never as a fatal error. + * Run a verification compile of `verifyProjectData` against `target`. + * Both platform impls internally drive the shared `runCompilePipeline` — + * the only thing they own is the platform-specific arg assembly (board + * entry, hals data, firmware skeleton) and the transport. Failures are + * advisory: the orchestrator surfaces them as a warning on the build + * result, never as a fatal error. + * + * Never called with `target.mode === 'off'` — the orchestrator skips + * verification entirely in that case. */ verifyCompile(args: VerifyCompileArgs): Promise } diff --git a/src/middleware/shared/ports/library-port.ts b/src/middleware/shared/ports/library-port.ts index ad22d1358..e6978077d 100644 --- a/src/middleware/shared/ports/library-port.ts +++ b/src/middleware/shared/ports/library-port.ts @@ -100,6 +100,16 @@ export interface StlibArchiveDTO { variables: unknown[] documentation?: 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 + }> } export interface LibraryPort { diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 303e15d53..adf0954e4 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -31,6 +31,17 @@ import type { DeviceConfiguration, DevicePin, PLCProjectData, ProjectMeta, RecentProject, Unsubscribe } from './types' +/** + * One library folder under a library project's `resources/` — the ordinary + * Arduino layout (`library.properties` beside `src/`), packaged verbatim into + * the `.stlib` for the consuming project to compile. + */ +export interface LibraryResourceFolder { + name: string + /** Paths relative to the folder, `/`-separated and sorted. */ + files: string[] +} + export interface CreateProjectParams { name: string type: 'plc-project' | 'plc-library' @@ -358,6 +369,30 @@ export interface ProjectPort { error?: string }> + /** + * The library folders under a library project's `resources/`, each with + * the files it ships. Optional: only a library project has the + * directory, and only the desktop editor manages it today. + */ + listLibraryResources?(): Promise<{ success: boolean; folders?: LibraryResourceFolder[]; error?: string }> + + /** + * Ask the user for a library folder and copy it into `resources/`. + * `canceled` distinguishes a dismissed picker from a failure, so the + * caller can stay silent rather than reporting an error the user caused + * on purpose. + * Editor: native open-directory dialog, recursive copy. + */ + addLibraryResource?(): Promise<{ + success: boolean + canceled?: boolean + folder?: LibraryResourceFolder + error?: string + }> + + /** Remove one library folder from `resources/`. */ + removeLibraryResource?(folderName: string): Promise<{ success: boolean; error?: string }> + /** * Pick a PLCopen XML file to import and read its contents. * Editor: native open-file dialog filtered to .xml. diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 191053bfe..7a6a00026 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -1272,12 +1272,12 @@ export interface DebugCompileResult { * shape of `CompileResult` (success / error) plus the artefact path * the console surfaces so the user can find the produced archive. * - * The verification step (Phase 8 — running the synthetic project - * through avr-gcc on the simulator target) reports its outcome - * through `verification`: missing means the step hasn't been wired - * yet; `success: true` means it ran clean; `success: false` does NOT - * fail the build, the warning surfaces to the console instead (a - * legitimate target may have more memory than the AVR simulator). + * The verification step (compiling the synthetic project against the + * manifest's verify target) reports its outcome through `verification`: + * missing means it did not run — `build.verify: "off"`; `success: true` + * means it ran clean; `success: false` does NOT fail the build, the warning + * surfaces to the console instead, because the `.stlib` carries source and + * the consumer compiles it for its own board. */ export interface CompileLibraryResult { success: boolean diff --git a/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts b/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts index b78ab703b..dcc410576 100644 --- a/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts +++ b/src/middleware/shared/utils/library/__tests__/compose-runtime-v4-bundle.test.ts @@ -26,10 +26,54 @@ function baseInput(overrides: Partial = {}): Compos opcUa: null, ethercat: '{"masters":[]}', }, + libraryResources: [], ...overrides, } } +describe('composeRuntimeV4Bundle — library resources', () => { + it('writes each library folder under libraries/, as it stands', () => { + const files = composeRuntimeV4Bundle( + baseInput({ + libraryResources: [ + { + name: 'DemoProtocol', + files: [ + { path: 'library.properties', content: 'name=DemoProtocol\n' }, + { path: 'src/DemoApi.h', content: '// api\n' }, + { path: 'src/transport/DemoUdp.cpp', content: '// udp\n' }, + ], + }, + ], + }), + ) + // Same layout the firmware bundle uses. Makefile.strucpp puts every + // libraries/*/src on the include path and compiles beneath it, so a block + // resolves `#include ` exactly as it does on Arduino. + expect(files['libraries/DemoProtocol/src/DemoApi.h']).toBe('// api\n') + expect(files['libraries/DemoProtocol/src/transport/DemoUdp.cpp']).toBe('// udp\n') + expect(files['libraries/DemoProtocol/library.properties']).toBe('name=DemoProtocol\n') + }) + + it('cannot collide with a generated artefact, which all sit at the root', () => { + const files = composeRuntimeV4Bundle( + baseInput({ + libraryResources: [ + { + name: 'DemoProtocol', + files: [ + { path: 'generated.cpp', content: '// hijacked\n' }, + { path: 'src/generated.cpp', content: '// also fine\n' }, + ], + }, + ], + }), + ) + expect(files['generated.cpp']).toBe('// generated\n') + expect(files['libraries/DemoProtocol/generated.cpp']).toBe('// hijacked\n') + }) +}) + describe('composeRuntimeV4Bundle', () => { it('writes program.st at the zip root', () => { const files = composeRuntimeV4Bundle(baseInput()) diff --git a/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts b/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts new file mode 100644 index 000000000..652a9595f --- /dev/null +++ b/src/middleware/shared/utils/library/__tests__/manifest-build-block.test.ts @@ -0,0 +1,67 @@ +import { DEFAULT_VERIFY_TARGET, parseVerifyTarget, withVerifyTarget } from '../manifest-build-block' + +describe('parseVerifyTarget', () => { + it('defaults when the block is absent', () => { + expect(parseVerifyTarget({ name: 'lib' })).toEqual({ target: DEFAULT_VERIFY_TARGET }) + }) + + it('reads mode and core', () => { + expect(parseVerifyTarget({ build: { verify: 'arduino', core: 'esp32:esp32' } })).toEqual({ + target: { mode: 'arduino', core: 'esp32:esp32' }, + }) + }) + + it('reports an unknown mode', () => { + const result = parseVerifyTarget({ build: { verify: 'nope' } }) + expect(result).toEqual({ errors: [expect.stringMatching(/must be one of arduino, runtime, off/) as string] }) + }) + + it('reports a non-object block', () => { + expect(parseVerifyTarget({ build: [] })).toEqual({ + errors: [expect.stringMatching(/must be a JSON object/) as string], + }) + }) +}) + +describe('withVerifyTarget', () => { + const manifest = JSON.stringify({ name: 'lib', version: '1.0.0', namespace: 'lib' }, null, 2) + '\n' + + it('adds the block and round-trips through the parser', () => { + const updated = withVerifyTarget(manifest, { mode: 'arduino', core: 'esp32:esp32' }) + expect(updated).not.toBeNull() + expect(parseVerifyTarget(JSON.parse(updated as string) as Record)).toEqual({ + target: { mode: 'arduino', core: 'esp32:esp32' }, + }) + }) + + it('leaves the rest of the manifest and its key order alone', () => { + const updated = withVerifyTarget(manifest, { mode: 'off' }) as string + expect(Object.keys(JSON.parse(updated) as Record)).toEqual([ + 'name', + 'version', + 'namespace', + 'build', + ]) + expect((JSON.parse(updated) as { version: string }).version).toBe('1.0.0') + }) + + it('drops the core when the target no longer names one', () => { + const withCore = withVerifyTarget(manifest, { mode: 'arduino', core: 'esp32:esp32' }) as string + const withoutCore = withVerifyTarget(withCore, { mode: 'arduino' }) as string + expect(JSON.parse(withoutCore)).toMatchObject({ build: { verify: 'arduino' } }) + expect((JSON.parse(withoutCore) as { build: Record }).build).not.toHaveProperty('core') + }) + + it('keeps other keys already in the block', () => { + const seeded = JSON.stringify({ name: 'lib', build: { verify: 'arduino', future: 1 } }, null, 2) + const updated = withVerifyTarget(seeded, { mode: 'runtime' }) as string + expect(JSON.parse(updated)).toMatchObject({ build: { verify: 'runtime', future: 1 } }) + }) + + it('refuses a manifest that is not a JSON object', () => { + // The dialog can be opened while the Manifest tab holds a half-typed + // edit; overwriting it would discard the user's work. + expect(withVerifyTarget('{ not json', { mode: 'off' })).toBeNull() + expect(withVerifyTarget('[]', { mode: 'off' })).toBeNull() + }) +}) diff --git a/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts b/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts new file mode 100644 index 000000000..a2374c56a --- /dev/null +++ b/src/middleware/shared/utils/library/__tests__/pick-verify-board.test.ts @@ -0,0 +1,34 @@ +import { pickVerifyBoard } from '../pick-verify-board' + +const CANDIDATES = [ + { name: 'OpenPLC Simulator', core: 'arduino:avr', compiler: 'simulator' }, + { name: 'Arduino Uno', core: 'arduino:avr', compiler: 'arduino-cli' }, + { name: 'Arduino Mega', core: 'arduino:avr', compiler: 'arduino-cli' }, + { name: 'ESP32-S3 Dev Module', core: 'esp32:esp32', compiler: 'arduino-cli' }, + { name: 'OpenPLC Runtime v4', compiler: 'openplc-compiler' }, +] + +describe('pickVerifyBoard', () => { + it('prefers a real board over the in-process simulator', () => { + // The simulator is a faked ATmega — a poor stand-in for a core that has + // actual hardware behind it. + expect(pickVerifyBoard(CANDIDATES, 'arduino:avr')).toBe('Arduino Mega') + }) + + it('breaks ties by name, so install order does not change the answer', () => { + const reversed = [...CANDIDATES].reverse() + expect(pickVerifyBoard(reversed, 'arduino:avr')).toBe('Arduino Mega') + }) + + it('returns the only board of a core', () => { + expect(pickVerifyBoard(CANDIDATES, 'esp32:esp32')).toBe('ESP32-S3 Dev Module') + }) + + it('returns null when no board carries the core', () => { + expect(pickVerifyBoard(CANDIDATES, 'rp2040:rp2040')).toBeNull() + }) + + it('falls back to the simulator when it is the only board of the core', () => { + expect(pickVerifyBoard([CANDIDATES[0]], 'arduino:avr')).toBe('OpenPLC Simulator') + }) +}) diff --git a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts index 310d83d18..4b6becf3c 100644 --- a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts +++ b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts @@ -55,14 +55,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 }> }> /** 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 @@ -100,6 +110,15 @@ export interface ComposeRuntimeV4BundleInput { 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.content + } + } + // 1. Concatenated ST program (ST transpiler output) files['program.st'] = input.programSt 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..800b78897 --- /dev/null +++ b/src/middleware/shared/utils/library/pick-verify-board.ts @@ -0,0 +1,40 @@ +// 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 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 +} From 07b1a19cdf4f3ccda3ff9a7a1f30ad96f15b58e1 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Mon, 31 Aug 2026 23:09:28 +1200 Subject: [PATCH 02/13] Carry generic parameters and a declared string length through the editor, and ship a library folder as a library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generics reach the compiler now. PLCopen TC6 puts ANY and its family in the elementaryTypes group, so they are element tags rather than , and generic-types.ts owns that mapping at the XML edge in both directions — for variables, return types and structure fields, across both generators. Internally they stay user-data-type, not base-type: base-type values are validated against the elementary registry, which a generic is deliberately absent from, so calling one a base type would make a project that merely mentions ANY fail its own schema on save. A native block pin typed with one emits IEC_ANY, and isDescriptorPinType keeps the C-block generator from treating the descriptor as a user type. ARRAY [*] pins emit ArrayView1D/2D and are passed as the view itself — an element pointer would drop the length and index data_[0 - lower], out of range for any non-zero lower bound. Every STRING compiled to IECStringVar<254> — 518 bytes — whatever was declared, because a length was not representable: baseTypeSchema was a flat enum of type names. Measured on an ESP32-S3, 100 function block instances with STRING pins cost 104,920 bytes of globals; declared STRING(23) they cost 11,656. The length now lives in type.value with lookupBaseType stripping it before the registry lookup, so the thirty-odd existing call sites keep working; the three declaration parsers accept it, PLCopen XML carries it both ways on the TC6 length attribute, and a native pin emits IECStringVar<23> so _VARS matches what strucpp declared. It is settable from the GUI too. A shared StringLengthMenuItem puts a length box beside STRING and WSTRING in every type picker — POU variables, globals, DUT structures, and the selector behind all three array modals — with a Length field in the graphical create-variable modal, which uses a native select. Empty picks the unqualified type. The type cell rendered through lodash upperCase, which splits on punctuation, so a declared length read back as "STRING 15" and a DUT named S_MOTOR as "S MOTOR". Forcing a string was a silent no-op: the encoder sent 1 + text.length, and the runtime compares the received length against the fixed 127-byte window and refuses anything below it, so the flag showed set while the value never moved. FUNCTION_BLOCK X EXTENDS Y parsed and was then dropped by eight places that each restate a POU field by field — the declaration regex, the text and signature serializers, the ST emitter, compiler-adapter, project-adapter in both directions, ipc-pou-to-flat, and the JSON branch of parse-project-files — so the compiler saw a block with no base, no inherited pins and no dynamic binding. Two things the debugger could reach by raw path but not name. findFunctionBlockVariables stopped at a block's own declarations, so an inherited member had no main:. key; it now walks the chain base-first, a derived declaration hiding a base one. And an array data type used as a variable collapsed to one leaf while an identical inline ARRAY [..] OF .. expanded, because the user-data-type branch handled structures and enumerations only. A pin typed by a library's own data type was spelled strucpp::MB_SPACE * against strucpp's IEC_MB_SPACE, and failed to compile — but only in the consuming project, never in the library where the type is a project type. projectAndLibraryTypeNames replaces the project-only list and is threaded through the compiler module into both C-block generators. Library resources: one allow-list — library.properties and everything under src/, which is what arduino-cli and the Runtime v4 Makefile resolve — shared by the picker and the build, so what is copied in is what ships and an author's build/ and .git/ are never walked. A folder that is not a library is refused at the picker naming what is missing, rather than landing empty and failing much later. A precompiled .a travels base64 through the bundle, as a BundleFile union so the compiler finds every write site. The POU prefix becomes the manifest's namespace rather than its name: name is only checked for path safety, so a hyphenated my-lib produced my-lib__FOO, which no ST parser accepts, failing in the consuming project on a POU nobody wrote. And a new `library build | install | list` CLI command. Its debug sibling had been taking the bundle path from process.argv[1], which on Linux is a Chromium switch the Electron shim puts ahead of the script. --- .../editor/compiler/compiler-module.ts | 58 ++-- .../compiler/desktop-library-build-port.ts | 28 ++ .../compiler/editor-compiler-platform-port.ts | 40 ++- .../library-resources-service.test.ts | 42 ++- .../library-resources-service/index.ts | 71 ++++- .../project-service/utils/read-project.ts | 7 +- src/backend/editor/utils/ipc-pou-to-flat.ts | 2 + .../__tests__/compose-firmware-bundle.test.ts | 24 +- src/backend/shared/compile/pipeline.ts | 5 +- .../compile/steps/compose-firmware-bundle.ts | 9 +- .../library/__tests__/build-pipeline.test.ts | 33 +-- .../__tests__/inject-library-blocks.test.ts | 104 ++++++- .../library-build-orchestrator.test.ts | 98 ++++++- src/backend/shared/library/build-pipeline.ts | 20 +- .../shared/library/inject-library-blocks.ts | 74 ++++- .../library/library-build-orchestrator.ts | 81 ++++-- .../st-transpiler/emit/pou-textual.ts | 5 + .../transpilers/st-transpiler/from-schema.ts | 5 + .../shared/transpilers/st-transpiler/types.ts | 2 + src/backend/shared/types/PLC/open-plc.ts | 6 + .../cpp/__tests__/generateCBlocksCode.test.ts | 48 ++++ .../shared/utils/cpp/generateCBlocksCode.ts | 15 +- .../shared/utils/parse-project-files.ts | 1 + src/cli/__tests__/library.test.ts | 82 ++++++ src/cli/commands/library.ts | 257 ++++++++++++++++++ src/cli/main.ts | 13 +- .../_atoms/string-length-menu-item/index.tsx | 78 ++++++ .../_atoms/type-dropdown-selector/index.tsx | 41 ++- .../structure/table/selectable-cell.tsx | 44 ++- .../selectable-cell.tsx | 47 +++- .../variables-table/selectable-cell.tsx | 43 ++- .../create-graphical-variable-modal.tsx | 29 +- .../__tests__/array-codegen-helpers.test.ts | 163 +++++++++++ .../PLC/__tests__/generic-types-xml.test.ts | 104 +++++++ .../PLC/__tests__/sized-string-xml.test.ts | 83 ++++++ .../utils/PLC/array-codegen-helpers.ts | 87 ++++++ .../utils/PLC/data-type-text-parser.ts | 4 +- src/frontend/utils/PLC/generic-types.ts | 46 ++++ .../PLC/global-variable-list-text-parser.ts | 2 +- .../utils/PLC/pou-signature-serializer.ts | 5 + src/frontend/utils/PLC/pou-text-parser.ts | 30 +- src/frontend/utils/PLC/pou-text-serializer.ts | 4 + .../utils/PLC/xml-generator/base-type-tag.ts | 15 +- .../xml-generator/codesys/data-type-xml.ts | 5 +- .../PLC/xml-generator/codesys/pou-xml.ts | 11 +- .../PLC/xml-generator/old-editor/type-xml.ts | 18 +- src/frontend/utils/PLC/xml-parser/type-xml.ts | 34 ++- .../generate-iec-string-to-variables.test.ts | 115 +++++++- .../__tests__/iec-types-registry.test.ts | 87 ++++++ .../utils/__tests__/pou-helpers.test.ts | 134 +++++++++ .../utils/__tests__/variable-sizes.test.ts | 25 +- .../cpp/__tests__/generateSTCode.test.ts | 27 ++ src/frontend/utils/cpp/generateSTCode.ts | 16 +- src/frontend/utils/debug-tree-traversal.ts | 9 + .../utils/generate-iec-string-to-variables.ts | 68 ++--- src/frontend/utils/iec-types-registry.ts | 47 +++- src/frontend/utils/pou-helpers.ts | 100 ++++++- src/frontend/utils/variable-sizes.ts | 7 +- .../adapters/editor/compiler-adapter.ts | 4 + .../adapters/editor/project-adapter.ts | 7 + .../shared/ports/compiler-platform-port.ts | 19 +- .../shared/ports/library-build-port.ts | 18 ++ src/middleware/shared/ports/library-port.ts | 4 + src/middleware/shared/ports/plc-schemas.ts | 24 +- src/middleware/shared/ports/types.ts | 6 + .../shared/utils/library/bundle-file.ts | 19 ++ .../library/compose-runtime-v4-bundle.ts | 11 +- .../shared/utils/library/library-folder.ts | 41 +++ 68 files changed, 2522 insertions(+), 289 deletions(-) create mode 100644 src/cli/__tests__/library.test.ts create mode 100644 src/cli/commands/library.ts create mode 100644 src/frontend/components/_atoms/string-length-menu-item/index.tsx create mode 100644 src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts create mode 100644 src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts create mode 100644 src/frontend/utils/PLC/generic-types.ts create mode 100644 src/middleware/shared/utils/library/bundle-file.ts create mode 100644 src/middleware/shared/utils/library/library-folder.ts diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index c32a9d8eb..371d1cb26 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -26,6 +26,7 @@ import { runWithConcurrencyLimit } from './run-with-concurrency' type StrucppCompileError = import('strucpp').CompileError import { buildArduinoCliCompileArgs } from '@root/backend/shared/firmware/build-arduino-cli-args' +import { projectAndLibraryTypeNames } from '@root/backend/shared/library/inject-library-blocks' import { runLibraryBuildPipeline } from '@root/backend/shared/library/library-build-orchestrator' import { parseNativePouRefs } from '@root/backend/shared/library/native-pou-list' import { buildKnownPous, emitCompileErrorEvents } from '@root/backend/shared/library/program-build-helpers' @@ -1431,6 +1432,12 @@ class CompilerModule { projectData: ProjectDataWithCppPous, sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, + /** Every data type in scope — the project's own and the enabled libraries'. + * Tells a structure or enumeration, which strucpp aliases as `IEC_`, + * from a function block instance, a bare `class `; the variable + * alone cannot say. Built by `projectAndLibraryTypeNames`, which is why it + * is passed rather than derived: only the caller holds the archives. */ + typeNames: string[], ) { const originalCppPous = projectData.originalCppPous || [] @@ -1444,11 +1451,7 @@ class CompilerModule { variables: pou.variables, })) as CppPouDataHeader[] - // The project's data-type names let the generator tell a structure or - // enumeration (which strucpp aliases as `IEC_`) from a function block - // instance (a bare `class `), which the variable alone cannot say. - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const headerContent: string = generateCBlocksHeader(cppPous, userTypeNames) + const headerContent: string = generateCBlocksHeader(cppPous, typeNames) const headerFilePath = join(sourceTargetFolderPath, 'c_blocks.h') try { @@ -1468,6 +1471,12 @@ class CompilerModule { // future runtime might branch off this discriminator again. _boardRuntime: string, handleOutputData: HandleOutputDataCallback, + /** Every data type in scope — the project's own and the enabled libraries'. + * Tells a structure or enumeration, which strucpp aliases as `IEC_`, + * from a function block instance, a bare `class `; the variable + * alone cannot say. Built by `projectAndLibraryTypeNames`, which is why it + * is passed rather than derived: only the caller holds the archives. */ + typeNames: string[], ) { const originalCppPous = projectData.originalCppPous || [] @@ -1481,10 +1490,7 @@ class CompilerModule { // -std=gnu++17. The static Baremetal/c_blocks_code.cpp baseline stays // strucpp-free and is compiled by arduino-cli in the core's native // standard. - // Every data type the project declares is aliased into the block's scope, - // including ones reachable only through a structure member. - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) - const codeContent = generateCBlocksCode(cppPous, userTypeNames) + const codeContent = generateCBlocksCode(cppPous, typeNames) const codeFilePath = join(compilationPath, 'src', 'c_blocks_code.cpp') try { @@ -3432,13 +3438,18 @@ class CompilerModule { return } + // Resolved once, outside the compile step: the C-blocks header and code + // below need the same archives, to spell a pin typed by a library's own + // data type the way strucpp declared it. + const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) + const { archives: libraries, missing: missingLibraries } = + mainProcessBridge.loadEnabledArchives(enabledLibraryNames) + const typeNames = projectAndLibraryTypeNames(projectData, libraries) + // Compile ST to C++ with STruC++ (replaces iec2c + debug + glue generation) try { const hasCBlocks = ((projectData as ProjectDataWithCppPous).originalCppPous?.length ?? 0) > 0 const knownPous = buildKnownPous(projectData.pous) - const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) - const { archives: libraries, missing: missingLibraries } = - mainProcessBridge.loadEnabledArchives(enabledLibraryNames) await this.handleCompileSTtoCpp( sourceTargetFolderPath, (data, logLevel, compileError) => { @@ -3465,9 +3476,14 @@ class CompilerModule { // Generate C/C++ blocks header file try { - await this.handleGenerateCBlocksHeader(projectData, sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) + await this.handleGenerateCBlocksHeader( + projectData, + sourceTargetFolderPath, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + typeNames, + ) } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', @@ -3483,9 +3499,15 @@ class CompilerModule { // Generate C/C++ blocks code file try { - await this.handleGenerateCBlocksCode(projectData, compilationPath, boardRuntime, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) + await this.handleGenerateCBlocksCode( + projectData, + compilationPath, + boardRuntime, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + typeNames, + ) } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index 9fa748b0a..b4f2bd204 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -109,6 +109,34 @@ 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 }) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index f365af655..0505046c3 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -52,6 +52,8 @@ import type { UploadRuntimeV3Args, UploadRuntimeV4Args, } from '@root/middleware/shared/ports/compiler-platform-port' +import type { BundleFile } from '@root/middleware/shared/utils/library/bundle-file' +import { isBinaryBundleFile } from '@root/middleware/shared/utils/library/bundle-file' import { createHash } from 'crypto' import { promises as fs } from 'fs' import { dirname, join } from 'path' @@ -64,6 +66,26 @@ import type { CompilerModule } from './compiler-module' * port stays free of the wider class surface (logging internals, * file-watching, etc.). */ + +/** + * Write one bundle entry, creating its parent directories. + * + * A bundle is nearly all generated text, but a library that declares + * `precompiled=true` ships a `.a`, which reaches here base64-encoded because + * the archive it travelled in is JSON. Writing that as text would produce a + * file the linker rejects, with nothing in the message pointing back here. + */ +async function writeBundleFile(absPath: string, file: BundleFile): Promise { + await fs.mkdir(dirname(absPath), { recursive: true }) + if (isBinaryBundleFile(file)) { + // Copied into a plain `Uint8Array`: `Buffer` and the `Uint8Array` this + // TypeScript lib expects are not assignable to one another. + await fs.writeFile(absPath, new Uint8Array(Buffer.from(file.base64, 'base64'))) + return + } + await fs.writeFile(absPath, file, 'utf-8') +} + export interface EditorCompilerHandlers { handleCompileArduinoProgram: CompilerModule['handleCompileArduinoProgram'] handleUploadProgram: CompilerModule['handleUploadProgram'] @@ -290,11 +312,9 @@ export function createEditorCompilerPlatformPort( // compile pipeline; doing it here once preserves the same // on-disk layout arduino-cli expects. await Promise.all( - Object.entries(args.files).map(async ([relPath, content]) => { - const absPath = join(context.compilationPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - await fs.writeFile(absPath, content, 'utf-8') - }), + Object.entries(args.files).map(async ([relPath, content]) => + writeBundleFile(join(context.compilationPath, relPath), content), + ), ) // Invoke the existing handler — it spawns arduino-cli compile @@ -590,13 +610,11 @@ export function createEditorCompilerPlatformPort( log: PlatformLog, ): Promise { try { - const entries: Array<[string, string]> = Object.entries(args.bundle) + const entries: Array<[string, BundleFile]> = Object.entries(args.bundle) await Promise.all( - entries.map(async ([relPath, content]: [string, string]) => { - const absPath = join(context.sourceTargetFolderPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - await fs.writeFile(absPath, content, 'utf-8') - }), + entries.map(async ([relPath, content]: [string, BundleFile]) => + writeBundleFile(join(context.sourceTargetFolderPath, relPath), content), + ), ) return { written: entries.length } } catch (error) { diff --git a/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts b/src/backend/editor/services/library-resources-service/__tests__/library-resources-service.test.ts index a13e33008..1295f361e 100644 --- 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 @@ -93,17 +93,45 @@ describe('addLibraryResource', () => { expect(second.error).toMatch(/already in resources/) }) - it('leaves .git and node_modules behind', async () => { + 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') - mkdirSync(join(source, '.git'), { recursive: true }) - writeFileSync(join(source, '.git', 'HEAD'), 'ref: refs/heads/main\n') - mkdirSync(join(source, 'node_modules', 'dep'), { recursive: true }) - writeFileSync(join(source, 'node_modules', 'dep', 'index.js'), '\n') + 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.some((file) => file.startsWith('.git/'))).toBe(false) - expect(result.folder?.files.some((file) => file.startsWith('node_modules/'))).toBe(false) + 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 () => { diff --git a/src/backend/editor/services/library-resources-service/index.ts b/src/backend/editor/services/library-resources-service/index.ts index 165b6748e..ca88b6a0c 100644 --- a/src/backend/editor/services/library-resources-service/index.ts +++ b/src/backend/editor/services/library-resources-service/index.ts @@ -20,6 +20,13 @@ 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' @@ -27,17 +34,12 @@ import { assertPathContained } from '../../utils/path-containment' const RESOURCES_DIR = 'resources' /** - * Bounds on a folder being added. A picker makes it one click to choose a - * home directory by mistake, and the copy would otherwise run until the disk - * filled. Generous enough that no real library approaches them. + * 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 -/** Directories never carried into `resources/`: version-control and - * dependency trees belong to the source repository, not to the library. */ -const SKIPPED_DIRS = new Set(['.git', 'node_modules']) - /** One library folder under `resources/`, with the files it ships. */ export interface LibraryResourceFolder { name: string @@ -100,6 +102,11 @@ export async function addLibraryResource(projectPath: string, sourcePath: string // 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 } @@ -110,7 +117,12 @@ export async function addLibraryResource(projectPath: string, sourcePath: string // A link out of the tree would put files the author never chose into a // published archive. dereference: false, - filter: (source) => !SKIPPED_DIRS.has(basename(source)), + // `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) } @@ -145,6 +157,32 @@ export async function removeLibraryResource( } } +/** + * 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 @@ -165,11 +203,12 @@ async function measure(root: string): Promise<{ files: number; bytes: number } | 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 (!SKIPPED_DIRS.has(entry.name)) stack.push(full) + if (isLibraryDir(rel)) stack.push(full) continue } - if (!entry.isFile()) 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.` } @@ -185,8 +224,9 @@ async function measure(root: string): Promise<{ files: number; bytes: number } | return { files, bytes } } -/** Every file under `root`, relative and `/`-separated, sorted. Symlinks are - * not followed, matching what the build packages. */ +/** 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] @@ -201,10 +241,11 @@ async function walkFiles(root: string): Promise { 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()) { - stack.push(full) - } else if (entry.isFile()) { - found.push(relative(root, full).split(sep).join('/')) + if (isLibraryDir(rel)) stack.push(full) + } else if (entry.isFile() && isLibraryFile(rel)) { + found.push(rel) } } } 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 91a173932..ce3af5c0c 100644 --- a/src/backend/editor/services/project-service/utils/read-project.ts +++ b/src/backend/editor/services/project-service/utils/read-project.ts @@ -342,7 +342,7 @@ function readAndParsePouFile(filePath: string, fileName: string): PLCPou { const portPou = pou as unknown as { name: string pouType: string - interface?: { returnType?: string; variables: unknown[] } + interface?: { returnType?: string; extends?: string; variables: unknown[] } body: { language: string; value: unknown } documentation?: string } @@ -353,6 +353,11 @@ function readAndParsePouFile(filePath: string, fileName: string): PLCPou { name: portPou.name, variables: portPou.interface?.variables ?? [], ...(portPou.pouType === 'function' ? { returnType: portPou.interface?.returnType ?? '' } : {}), + // Only a function block may extend another. This flattening names each + // field explicitly, so anything unlisted is dropped. + ...(portPou.pouType === 'function-block' && portPou.interface?.extends + ? { extends: portPou.interface.extends } + : {}), body: portPou.body, documentation: portPou.documentation ?? '', }, diff --git a/src/backend/editor/utils/ipc-pou-to-flat.ts b/src/backend/editor/utils/ipc-pou-to-flat.ts index 2feecb894..c6bafc1b3 100644 --- a/src/backend/editor/utils/ipc-pou-to-flat.ts +++ b/src/backend/editor/utils/ipc-pou-to-flat.ts @@ -12,6 +12,8 @@ export function ipcPouToFlat(pou: IpcPou): FlatPou & { variablesText?: string } pouType: pou.type as FlatPou['pouType'], interface: { returnType: (data.returnType as string | undefined) ?? undefined, + // Named explicitly, or `serializePouToText` drops the EXTENDS clause. + ...(data.extends ? { extends: data.extends as string } : {}), variables: (data.variables ?? []) as NonNullable['variables'], }, body: pou.data.body as FlatPou['body'], diff --git a/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts index 204f3d3b2..fb31e4d38 100644 --- a/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts +++ b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts @@ -13,7 +13,7 @@ import { buildCBlocksFromPous, composeFirmwareBundle } from '../steps/compose-fi const baseInput = { strucppFiles: {}, - libraryResources: [] as Array<{ name: string; files: Array<{ path: string; content: string }> }>, + 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: {}, @@ -36,6 +36,28 @@ describe('composeFirmwareBundle — library resources', () => { 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] }) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index f29488eb4..efbaec724 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -37,6 +37,7 @@ import { describeVppRuntimeMismatch, isStrucppCompatibleRuntime, } from '../firmware/runtime-version-gate' +import { projectAndLibraryTypeNames } from '../library/inject-library-blocks' import { buildKnownPous, emitCompileErrorEvents } from '../library/program-build-helpers' import { runProgramBuildPipeline } from '../library/program-build-pipeline' import type { DevicePin } from '../types/PLC/devices' @@ -600,7 +601,9 @@ async function runCompilePipelineInner( } emit({ stage: 'runtime-v4-bundle', message: 'Composing Runtime v4 upload bundle...', level: 'info' }) - const userTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + // The enabled libraries' data types count as well as the project's: a pin + // typed by one has to be spelled the way strucpp declared it. + const userTypeNames = projectAndLibraryTypeNames(projectData, libraryArchives) const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) const bundle = composeRuntimeV4Bundle({ programSt, diff --git a/src/backend/shared/compile/steps/compose-firmware-bundle.ts b/src/backend/shared/compile/steps/compose-firmware-bundle.ts index ce11d486d..7b5511c41 100644 --- a/src/backend/shared/compile/steps/compose-firmware-bundle.ts +++ b/src/backend/shared/compile/steps/compose-firmware-bundle.ts @@ -30,6 +30,7 @@ * from one to the other without re-deriving inputs. */ +import type { BundleFile } from '../../../../middleware/shared/utils/library/bundle-file' import type { CppPouData as CppPouDataCode } from '../../utils/cpp/generateCBlocksCode' import { generateCBlocksCode } from '../../utils/cpp/generateCBlocksCode' import type { CppPouData as CppPouDataHeader } from '../../utils/cpp/generateCBlocksHeader' @@ -54,7 +55,7 @@ export interface ComposeFirmwareBundleInput { /** 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 }> }> + libraryResources: Array<{ name: string; files: Array<{ path: string; content: string; encoding?: 'base64' }> }> cBlocks: { header: string code: string | null @@ -162,10 +163,10 @@ function libraryProperties(name: string): string { ].join('\n') } -export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { +export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { const { strucppFiles, cBlocks, definesH, vppConfigH, firmwareSkeleton, libraryResources } = input - const files: Record = {} + 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/`. @@ -173,7 +174,7 @@ export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record for (const library of libraryResources) { const root = `libraries/${library.name}` for (const file of library.files) { - files[`${root}/${file.path}`] = file.content + 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) diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index df23b0d13..b2170ac53 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -753,10 +753,12 @@ describe('libraryBuildFromTranspiledSt', () => { expect('resources' in noRes).toBe(false) }) - it('refuses a C/C++ library whose name is not a valid C identifier', () => { - // The name is emitted verbatim as `__`, so a hyphen reaches - // the compiler as a subtraction. - const compileStlib = jest.fn() + 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'] }), ) @@ -770,29 +772,6 @@ describe('libraryBuildFromTranspiledSt', () => { }, ) - expect(res.success).toBe(false) - expect(res.errors[0]?.message).toMatch(/valid C identifier/) - expect(compileStlib).not.toHaveBeenCalled() - }) - - it('leaves a non-identifier name alone when the library ships no native blocks', () => { - // Such a name never reaches C, and rejecting it would refuse libraries - // that build today. - const compileStlib = jest.fn().mockReturnValue({ success: true, archive: { manifest: {}, dependencies: [] } }) - __setStrucppRuntimeForTests( - makeStrucppStub({ compileStlib: compileStlib as unknown as StrucppRuntime['compileStlib'] }), - ) - - const res = libraryBuildFromTranspiledSt( - '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', - [ - { name: 'Tank', kind: 'FUNCTION_BLOCK' }, - { name: STUB.STUB_PROGRAM_NAME, kind: 'PROGRAM' }, - ], - { ...manifest, name: 'demo-lib', namespace: 'demo_lib' }, - ) - expect(res.success).toBe(true) expect(compileStlib).toHaveBeenCalled() }) diff --git a/src/backend/shared/library/__tests__/inject-library-blocks.test.ts b/src/backend/shared/library/__tests__/inject-library-blocks.test.ts index f2f9e3a41..71b3b0170 100644 --- a/src/backend/shared/library/__tests__/inject-library-blocks.test.ts +++ b/src/backend/shared/library/__tests__/inject-library-blocks.test.ts @@ -1,6 +1,11 @@ import type { StlibArchiveDTO } from '../../../../middleware/shared/ports/library-port' import type { PLCProjectData } from '../../../../middleware/shared/ports/types' -import { findLibrariesMissingNativeSources, injectLibraryBlocks, libraryBlockPouName } from '../inject-library-blocks' +import { + findLibrariesMissingNativeSources, + injectLibraryBlocks, + libraryBlockPouName, + projectAndLibraryTypeNames, +} from '../inject-library-blocks' // -- helpers ------------------------------------------------------------------ @@ -47,7 +52,7 @@ function project(overrides: { libraries?: Array<{ name: string; version: string function archive( name: string, blocks: Array<{ name: string; language: 'cpp' | 'python'; file?: string; source?: string | null }> = [], - opts: { stBlocks?: string[] } = {}, + opts: { stBlocks?: string[]; namespace?: string; types?: Array<{ name: string; kind: string }> } = {}, ): StlibArchiveDTO { const sources: Array<{ fileName: string; source: string }> = [] const functionBlocks: unknown[] = (opts.stBlocks ?? []).map((n) => ({ @@ -72,7 +77,16 @@ function archive( if (source !== null) sources.push({ fileName, source }) } - return { manifest: { name, version: '1.0.0', functionBlocks }, sources } as unknown as StlibArchiveDTO + return { + manifest: { + name, + version: '1.0.0', + functionBlocks, + ...(opts.namespace ? { namespace: opts.namespace } : {}), + ...(opts.types ? { types: opts.types } : {}), + }, + sources, + } as unknown as StlibArchiveDTO } // -- tests -------------------------------------------------------------------- @@ -83,6 +97,90 @@ describe('libraryBlockPouName', () => { }) }) +describe('the identifier a grafted block is prefixed with', () => { + // The prefix becomes an ST POU name. `manifest.name` is only checked for path + // safety, so `modbee-protocol` is a legal name — and produced + // `modbee-protocol__TOPIC`, which no parser accepts. The failure appeared + // only in the CONSUMING project, naming a POU nobody wrote. + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + + it('takes the namespace, so a hyphenated library name still parses', () => { + const grafted = injectLibraryBlocks(data, [ + archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }], { namespace: 'modbee_protocol' }), + ]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) + + it('falls back to folding the name when an older archive declares no namespace', () => { + const grafted = injectLibraryBlocks(data, [archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }])]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) + + it('does not let a folded name start with a digit', () => { + const numeric = project({ pous: ['main'], libraries: [{ name: '3d-tools', version: '1.0.0' }] }) + const grafted = injectLibraryBlocks(numeric, [archive('3d-tools', [{ name: 'MOVE', language: 'cpp' }])]) + expect(grafted.pous.map((pou) => pou.name)).toContain('_3d_tools__MOVE') + }) + + it('ignores a namespace that is not an identifier and folds instead', () => { + const grafted = injectLibraryBlocks(data, [ + archive('modbee-protocol', [{ name: 'TOPIC', language: 'cpp' }], { namespace: 'not an identifier' }), + ]) + expect(grafted.pous.map((pou) => pou.name)).toContain('modbee_protocol__TOPIC') + }) +}) + +describe('projectAndLibraryTypeNames', () => { + // The native bridge spells a pin `strucpp::IEC_` for a declared data + // type and `strucpp::` otherwise. Built from the project alone, a pin + // typed by a LIBRARY's enum was spelled bare while strucpp had declared + // `IEC_`, and the POU glue failed on the pointer assignment. + const enumType = { name: 'MB_SPACE', kind: 'enum' } + + it('includes the types an enabled library declares', () => { + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).toContain('MB_SPACE') + }) + + it('leaves out a library the project has not enabled', () => { + const data = project({ pous: ['main'] }) + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).not.toContain('MB_SPACE') + }) + + it('keeps the project own types alongside them', () => { + const data = { + ...project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }), + dataTypes: [{ name: 'MOTOR' }], + } as unknown as PLCProjectData + const names = projectAndLibraryTypeNames(data, [archive('modbee-protocol', [], { types: [enumType] })]) + expect(names).toEqual(expect.arrayContaining(['MOTOR', 'MB_SPACE'])) + }) + + it('includes every kind of data type, not just enumerations', () => { + // The bridge's IEC_ prefix rule applies to all three: strucpp aliases a + // structure and an array to themselves and an enumeration to IEC_ENUM<>, + // so a pin of any of them is spelled IEC_. + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + const names = projectAndLibraryTypeNames(data, [ + archive('modbee-protocol', [], { + types: [ + { name: 'MB_SPACE', kind: 'enum' }, + { name: 'MB_CFG', kind: 'struct' }, + { name: 'MB_TREND', kind: 'alias' }, + ], + }), + ]) + expect(names).toEqual(expect.arrayContaining(['MB_SPACE', 'MB_CFG', 'MB_TREND'])) + }) + + it('copes with an archive that declares no types at all', () => { + const data = project({ pous: ['main'], libraries: [{ name: 'modbee-protocol', version: '1.0.0' }] }) + expect(projectAndLibraryTypeNames(data, [archive('modbee-protocol')])).toEqual([]) + }) +}) + describe('injectLibraryBlocks', () => { it('returns the same object when the project enables no libraries', () => { const data = project({ pous: ['main'] }) diff --git a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts index 2fa3b3848..ed9296acc 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -108,6 +108,23 @@ function makePort(): PortHarness { if (relPath === 'library.json') return harness.manifestContent return harness.files.get(relPath) ?? null }, + async readBuildFileBase64(_projectPath: string, relPath: string) { + if (harness.throwOn.readBuildFileBase64) throw harness.throwOn.readBuildFileBase64 + const content = harness.files.get(relPath) + if (content === undefined) return null + return Buffer.from(content, 'utf-8').toString('base64') + }, + listProjectDirs(_projectPath: string, relPath: string) { + const prefix = `${relPath}/` + const names = new Set() + for (const key of harness.files.keys()) { + if (!key.startsWith(prefix)) continue + const rest = key.slice(prefix.length) + const slash = rest.indexOf('/') + if (slash > 0) names.add(rest.slice(0, slash)) + } + return Promise.resolve([...names].sort()) + }, async writeBuildFile(_projectPath: string, relPath: string, content: string) { if (harness.throwOn.writeBuildFile) throw harness.throwOn.writeBuildFile harness.files.set(relPath, content) @@ -371,12 +388,70 @@ describe('runLibraryBuildPipeline', () => { ]) }) - it('warns and skips a resource that is not inside a library folder', async () => { - // It belongs to no library, so there is nowhere for the consumer to - // materialise it. Reported rather than dropped in silence. + 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/stray.h', '#pragma once\n') + 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( @@ -391,15 +466,24 @@ describe('runLibraryBuildPipeline', () => { ) const [, , , aux] = mockLibraryBuild.mock.calls[0] - expect(aux.resources).toEqual([{ path: 'DemoProtocol/src/DemoApi.h', content: '// api\n' }]) - expect(events.some((e) => e.level === 'warning' && e.message.includes('stray.h'))).toBe(true) + 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/src/DemoApi.h', content: '#pragma once\n' }] + 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', diff --git a/src/backend/shared/library/build-pipeline.ts b/src/backend/shared/library/build-pipeline.ts index 0ef262409..5b8a41af6 100644 --- a/src/backend/shared/library/build-pipeline.ts +++ b/src/backend/shared/library/build-pipeline.ts @@ -338,7 +338,13 @@ export interface LibraryNativeSource { */ 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 { @@ -435,20 +441,6 @@ export function libraryBuildFromTranspiledSt( } } - // `injectLibraryBlocks` emits each grafted POU as `__`, a C - // identifier, but `checkPathId` permits `.` and `-`. Only libraries that - // ship native blocks reach C, so an ST-only library keeps any name it had. - if (nativeSources.length > 0 && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(manifest.name)) { - return { - success: false, - errors: [ - { - message: `manifest.name must be a valid C identifier (letters, digits, underscore; cannot start with a digit) because this library ships C/C++ blocks, which are emitted as \`${manifest.name}__\`. Got: ${JSON.stringify(manifest.name)}`, - }, - ], - } - } - // Native sources ride in the same input array; strucpp routes on the file // extension. Appended after the ST so `sourceFiles` ordering stays stable. const compileRes = compileStlib([...sources, ...nativeSources.map((n) => ({ ...n }))], { diff --git a/src/backend/shared/library/inject-library-blocks.ts b/src/backend/shared/library/inject-library-blocks.ts index 5d6c068a9..753cfd360 100644 --- a/src/backend/shared/library/inject-library-blocks.ts +++ b/src/backend/shared/library/inject-library-blocks.ts @@ -31,12 +31,16 @@ * * ## Renaming * - * Each block's name is prefixed with the library's manifest name - * (`__`) so two libraries can both ship a `Foo`, and so a + * Each block's name is prefixed with the library's `namespace` + * (`__`) so two libraries can both ship a `Foo`, and so a * consumer's own POU may also be called `Foo`. The library-tree picker * surfaces the prefixed name, so the user authors their ST against it directly * and no source rewriting is needed. * + * The prefix is the NAMESPACE, not `manifest.name`: `name` is checked only for + * path safety, so a hyphenated `my-lib` would yield `my-lib__FOO`, which no ST + * parser accepts. `namespace` is validated as a C++ identifier. + * * Symbol-level renames inside the synthesized POU (the `_VARS` struct, * the `_setup` / `_loop` functions) follow automatically, because * `generateCppSTCode`, `generateCBlocksHeader` and `generateCBlocksCode` all @@ -50,14 +54,34 @@ import type { PLCPou, PLCProjectData } from '../../../middleware/shared/ports/ty /** Separator between the library name and the block name. */ const LIBRARY_BLOCK_SEPARATOR = '__' -/** Build the project-visible POU name for a library block. */ -export function libraryBlockPouName(libraryName: string, blockName: string): string { - return `${libraryName}${LIBRARY_BLOCK_SEPARATOR}${blockName}` +/** + * Build the project-visible POU name for a library block. + * + * `libraryIdentifier` is the manifest's `namespace`, not its `name`: the result + * is parsed as an ST identifier. + */ +export function libraryBlockPouName(libraryIdentifier: string, blockName: string): string { + return `${libraryIdentifier}${LIBRARY_BLOCK_SEPARATOR}${blockName}` +} + +/** + * The identifier an archive's blocks are prefixed with. + * + * `namespace` is required of every manifest this editor builds, and validated + * as a C++ identifier. A foreign archive may lack one, so the name is folded + * into an identifier rather than trusted as it stands. + */ +function libraryIdentifierOf(manifest: { name: string; namespace?: string }): string { + const declared = manifest.namespace + if (declared && /^[A-Za-z_][A-Za-z0-9_]*$/.test(declared)) return declared + const folded = manifest.name.replace(/[^A-Za-z0-9_]/g, '_') + return /^[0-9]/.test(folded) ? `_${folded}` : folded } /** One native block an archive ships, resolved to its authored source. */ type ResolvedNativeBlock = { - libraryName: string + /** Identifier form, for the POU name — see the Renaming note above. */ + libraryIdentifier: string blockName: string language: 'cpp' | 'python' /** The authored file, verbatim — ST header plus native body. */ @@ -98,7 +122,7 @@ function resolveNativeBlocks( continue } blocks.push({ - libraryName, + libraryIdentifier: libraryIdentifierOf(archive.manifest), blockName: entry.name, language: entry.implementation, source, @@ -143,7 +167,7 @@ export function injectLibraryBlocks(projectData: PLCProjectData, archives: Stlib } synthesized.push({ ...parsed, - name: libraryBlockPouName(block.libraryName, block.blockName), + name: libraryBlockPouName(block.libraryIdentifier, block.blockName), pouType: 'function-block', }) } @@ -152,6 +176,40 @@ export function injectLibraryBlocks(projectData: PLCProjectData, archives: Stlib return { ...projectData, pous: [...projectData.pous, ...synthesized] } } +/** + * Every data-type name in scope for a compile: the project's own, plus those + * the enabled libraries declare. + * + * The native bridge spells a pin's type from this set: strucpp declares a + * POU member of a data type as `IEC_`, while a function block instance + * keeps its bare class name, and `mapUserTypeToIEC` tells them apart by + * membership. + * + * A library's types are emitted into the consuming project exactly as its own + * are, so they must be in the set too — built from the project alone, a pin + * typed by a library got `strucpp::MB_SPACE *` against strucpp's own + * `IEC_MB_SPACE`. + */ +export function projectAndLibraryTypeNames( + // Only the two fields this reads, so it takes the port shape and the schema + // shape alike — they differ on `configuration`/`configurations`, which is + // nothing to do with type names. + projectData: { dataTypes?: { name: string }[]; libraries?: { name: string }[] }, + archives: readonly unknown[], +): string[] { + const names = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + + const enabled = new Set((projectData.libraries ?? []).map((ref) => ref.name)) + for (const archive of archives as StlibArchiveDTO[]) { + const libraryName = archive?.manifest?.name + if (!libraryName || !enabled.has(libraryName)) continue + for (const type of archive.manifest.types ?? []) { + names.push(type.name) + } + } + return names +} + /** * Names of enabled libraries that declare native blocks whose source is * missing from the archive. diff --git a/src/backend/shared/library/library-build-orchestrator.ts b/src/backend/shared/library/library-build-orchestrator.ts index 22f85c158..c7f223031 100644 --- a/src/backend/shared/library/library-build-orchestrator.ts +++ b/src/backend/shared/library/library-build-orchestrator.ts @@ -38,6 +38,11 @@ import type { LibraryBuildPort } from '../../../middleware/shared/ports/library-build-port' import type { CompileLibraryResult } from '../../../middleware/shared/ports/types' +import { + LIBRARY_FOLDER_RULE, + LIBRARY_PROPERTIES, + LIBRARY_SRC_DIR, +} from '../../../middleware/shared/utils/library/library-folder' import type { PLCProject, PLCProjectData } from '../types/PLC/open-plc' import { isSafeRelativePath } from '../utils/path-safety' import { @@ -104,33 +109,67 @@ 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. * - * Reports and skips a file that is not inside a folder (it belongs to no - * library) or is not valid UTF-8 (the archive is JSON). + * 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 { - const paths = await port.listProjectFiles(projectPath, RESOURCES_REL_PATH) +): Promise<{ resources: LibraryResource[] } | { error: string }> { + const folders = await port.listProjectDirs(projectPath, RESOURCES_REL_PATH) const resources: LibraryResource[] = [] - for (const relPath of paths) { - if (!relPath.includes('/') || !isSafeRelativePath(relPath)) { - emit({ - message: `Skipping resource "${relPath}": it is not inside a library folder.`, - level: 'warning', - }) - continue + 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}.`, + } } - const content = await port.readBuildFile(projectPath, `${RESOURCES_REL_PATH}/${relPath}`) - if (content === null) continue - if (content.includes('\uFFFD')) { - emit({ message: `Skipping resource "${relPath}": not a text file.`, level: 'warning' }) - continue + + 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 }) } - resources.push({ path: relPath, content }) } - return resources + + 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 } } /** @@ -289,7 +328,11 @@ export async function runLibraryBuildPipeline( // included: a library whose C++ targets no toolchain the editor can drive // would otherwise carry a permanent failure that reports nothing. // ------------------------------------------------------------------------- - const resources = await readResources(port, projectPath, emit) + 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') diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts index 37bfd1ae6..42aad3281 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-textual.ts @@ -67,6 +67,11 @@ export function generateTextualPou(pou: TranspilePou, project: TranspileProject, program.push([' : ', []]) program.push([formatReturnType(pou.interface.returnType), [tagName, 'return']]) } + // Dropped here, a derived block reaches strucpp with no base clause, so it + // is emitted with no base class, no inherited pins and no dynamic binding. + if (pou.interface.extends) { + program.push([` EXTENDS ${pou.interface.extends}`, [tagName, 'extends']]) + } program.push(['\n', []]) const iface = computeInterface(pou.interface.variables) diff --git a/src/backend/shared/transpilers/st-transpiler/from-schema.ts b/src/backend/shared/transpilers/st-transpiler/from-schema.ts index 4d635c4af..593013787 100644 --- a/src/backend/shared/transpilers/st-transpiler/from-schema.ts +++ b/src/backend/shared/transpilers/st-transpiler/from-schema.ts @@ -139,6 +139,11 @@ function projectPou(pou: SchemaPou): TranspilePou { interface: { variables, ...(pou.type === 'function' ? { returnType: stringifyReturnType(pou.data.returnType) } : {}), + // Carried across, or the compile path emits the derived block with no + // base. Narrowed on `pou.type`: only that variant of the union carries it. + ...(pou.type === 'function-block' && pou.data.extends + ? { extends: pou.data.extends } + : {}), }, body: projectBody(pou.data.body), } diff --git a/src/backend/shared/transpilers/st-transpiler/types.ts b/src/backend/shared/transpilers/st-transpiler/types.ts index 02d50c2d3..5d9327251 100644 --- a/src/backend/shared/transpilers/st-transpiler/types.ts +++ b/src/backend/shared/transpilers/st-transpiler/types.ts @@ -45,6 +45,8 @@ export interface TranspilePou { export interface TranspilePouInterface { /** Only set on `function` POUs. */ returnType?: string + /** Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. */ + extends?: string variables: TranspileVariable[] } diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 25b4ef8b3..80a378ba6 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -241,6 +241,12 @@ type PLCInstance = z.infer const PLCFunctionBlockSchema = z.object({ language: z.enum(['il', 'st', 'ld', 'sfc', 'fbd', 'python', 'cpp']), name: z.string(), + /** + * Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. Only a function + * block may extend another, so this sits here and not on the FUNCTION or + * PROGRAM schema. + */ + extends: z.string().optional(), /** Array of variable - will be implemented */ variables: z.array(PLCVariableSchema), body: bodySchema, diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index 4d48efbc8..c7c3590b1 100644 --- a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts +++ b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts @@ -285,4 +285,52 @@ describe('generateCBlocksCode', () => { expect(result).toContain('#undef ioVar') expect(result).not.toContain('#undef hasBeenInitialized') }) + + it('does not alias a generic pin as if it were a project type', () => { + // A generic pin is a `user-data-type` by shape but names no project type: + // it resolves to the runtime's own IEC_ANY. Aliasing it emitted + // `using ANY_INT = strucpp::ANY_INT;` for a type that does not exist, and + // only a block declaring one would find out. + const result = generateCBlocksCode([ + { + name: 'SCALE', + code: 'void setup() { }\nvoid loop() { }', + variables: [ + { + name: 'raw', + class: 'input', + type: { definition: 'user-data-type', value: 'ANY_INT' }, + location: '', + documentation: '', + debug: false, + }, + ], + }, + ]) + + expect(result).not.toContain('using ANY_INT') + expect(result).not.toContain('strucpp::ANY_INT') + }) + + it('still aliases a real project type', () => { + const result = generateCBlocksCode([ + { + name: 'DRIVE', + code: 'void setup() { }\nvoid loop() { }', + variables: [ + { + name: 'motor', + class: 'input', + type: { definition: 'user-data-type', value: 'MOTOR' }, + location: '', + documentation: '', + debug: false, + }, + ], + }, + ]) + + expect(result).toContain('using MOTOR = strucpp::MOTOR;') + }) + }) diff --git a/src/backend/shared/utils/cpp/generateCBlocksCode.ts b/src/backend/shared/utils/cpp/generateCBlocksCode.ts index 7f17c813e..2bf9b0700 100644 --- a/src/backend/shared/utils/cpp/generateCBlocksCode.ts +++ b/src/backend/shared/utils/cpp/generateCBlocksCode.ts @@ -1,5 +1,9 @@ import { cBlockExternalVariables, cBlockInterfaceVariables } from '../../../../frontend/utils/cpp/block-interface' -import { isArrayVariable, multiDimensionalContainerType } from '../../../../frontend/utils/PLC/array-codegen-helpers' +import { + isArrayVariable, + isDescriptorPinType, + multiDimensionalContainerType, +} from '../../../../frontend/utils/PLC/array-codegen-helpers' import type { PLCVariable } from '../../../../middleware/shared/ports/types' type CppPouData = { @@ -168,7 +172,14 @@ const generateUserTypeAliases = (cppPous: CppPouData[], userTypeNames: Iterable< // `derived` is a function block instance, `user-data-type` a structure or // enumeration. A block may name either — casting to an enumeration, or // declaring a local of an FB class — so both belong in scope. - if (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') { + // A generic pin looks like a user type by name, but names no project + // type: it resolves to the runtime's own `IEC_ANY`, so aliasing it would + // emit `using ANY_INT = strucpp::ANY_INT;` for a type that does not + // exist. + if ( + (variable.type.definition === 'user-data-type' || variable.type.definition === 'derived') && + !isDescriptorPinType(variable.type.value) + ) { referenced.add(variable.type.value.toUpperCase()) } if (variable.type.definition === 'array' && variable.type.data?.baseType.definition === 'user-data-type') { diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 050dd4f49..d4ef71373 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -290,6 +290,7 @@ function parsePouFile(file: RawProjectFile, warnings: string[]): (PLCPou & { var pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType as string | undefined, + ...(ipcPou.data.extends ? { extends: ipcPou.data.extends as string } : {}), variables: (ipcPou.data.variables as PLCVariable[]) ?? [], }, body: ipcPou.data.body as PLCPou['body'], diff --git a/src/cli/__tests__/library.test.ts b/src/cli/__tests__/library.test.ts new file mode 100644 index 000000000..54696a01e --- /dev/null +++ b/src/cli/__tests__/library.test.ts @@ -0,0 +1,82 @@ +/** + * `openplc-cli library` — argument handling and result shaping. + * + * The heavy paths (a real build, a real install) belong to the modules this + * command drives and are covered where they live. What is tested here is the + * part the command owns: which subcommands exist, what a missing argument + * reports, and the shape of what comes back. + */ + +import { parseArgs } from '../args' +import { runLibrary } from '../commands/library' +import { ErrorCode, ExitCode } from '../exit-codes' +import { Reporter, type WriterStreams } from '../output' + +// The compiler drags in the hardware and package-manager modules, which want +// Electron's `app`. Nothing here builds, so it is stubbed rather than loaded. +jest.mock('@root/backend/editor/compiler', () => ({ + CompilerModule: jest.fn().mockImplementation(() => ({ compileLibrary: jest.fn() })), +})) + +jest.mock('../project/load', () => ({ + loadProject: jest.fn(), +})) + +jest.mock('@root/backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn().mockImplementation(() => ({ + listInstalled: () => [ + { name: 'modbee-protocol', version: '0.1.0', bundled: false, installedAt: '', origin: 'stlib' }, + ], + installFromFile: jest.fn(), + loadAll: () => [], + loadEnabledArchives: () => ({ archives: [], missing: [] }), + })), +})) + +function capture(): { streams: WriterStreams; out: string[]; err: string[] } { + const out: string[] = [] + const err: string[] = [] + return { streams: { out: (t) => out.push(t), err: (t) => err.push(t) }, out, err } +} + +const run = async (argv: string[]) => { + const { streams, out } = capture() + const reporter = new Reporter({ mode: 'json', streams }) + const result = await runLibrary(parseArgs(argv), reporter) + return { result, payload: out.length > 0 ? JSON.parse(out[0]) : undefined } +} + +describe('openplc-cli library', () => { + it('names the three subcommands when given none', async () => { + const { result, payload } = await run(['library']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.code).toBe(ErrorCode.InvalidArgument) + expect(payload.error.message).toContain('build, install or list') + }) + + it('rejects an unknown subcommand rather than guessing', async () => { + const { result, payload } = await run(['library', 'publish']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('publish') + }) + + it('asks for a project path when build is given none', async () => { + const { result, payload } = await run(['library', 'build']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('library project') + }) + + it('asks for a file when install is given none', async () => { + const { result, payload } = await run(['library', 'install']) + expect(result.exitCode).toBe(ExitCode.Usage) + expect(payload.error.message).toContain('.stlib') + }) + + it('lists what the library manager reports as installed', async () => { + const { result, payload } = await run(['library', 'list']) + expect(result.exitCode).toBe(ExitCode.Ok) + expect(payload.ok).toBe(true) + expect(payload.libraries).toHaveLength(1) + expect(payload.libraries[0].name).toBe('modbee-protocol') + }) +}) diff --git a/src/cli/commands/library.ts b/src/cli/commands/library.ts new file mode 100644 index 000000000..c190ac790 --- /dev/null +++ b/src/cli/commands/library.ts @@ -0,0 +1,257 @@ +/** + * `openplc-cli library` — build a Library Project into a `.stlib`, install one, + * and list what is installed. + * + * All three were GUI-only. Building ran through `CompilerModule.compileLibrary` + * over a MessagePort from the renderer; installing through + * `LibraryManagerModule`, which writes `/libraries//` AND a + * `registry.json` beside it — so copying an archive into place installs nothing. + * + * `build` enters `compileLibrary` at the same point the main process does, with + * the preprocessing the renderer's adapter does first. Reassembling those steps + * here would build a *different library* from the same sources, which is the + * kind of divergence that makes a green build worthless. + */ + +import { CompilerModule } from '@root/backend/editor/compiler' +import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import { collectNativePous } from '@root/backend/shared/library/native-pou-list' +import { preprocessPous } from '@root/backend/shared/utils/PLC/preprocess-pous' +import { toIpcProjectData } from '@root/middleware/adapters/editor/compiler-adapter' +import type { CompileLibraryResult, PLCProjectData } from '@root/middleware/shared/ports/types' + +import { boolFlag, type ParsedArgs } from '../args' +import { createHeadlessCompileBridge, createProgressChannel } from '../compile/headless-bridge' +import { ErrorCode, ExitCode } from '../exit-codes' +import { type CliResult, renderTable, type Reporter } from '../output' +import { loadProject } from '../project/load' + +export async function runLibrary(args: ParsedArgs, reporter: Reporter): Promise { + const [subcommand, target] = args.positionals + + switch (subcommand) { + case 'build': + return runLibraryBuild(args, reporter, target) + case 'install': + return runLibraryInstall(reporter, target) + case 'list': + return runLibraryList(reporter) + default: + return reporter.failure( + { + code: ErrorCode.InvalidArgument, + message: `library takes build, install or list — got "${subcommand ?? ''}".`, + }, + ExitCode.Usage, + ) + } +} + +async function runLibraryBuild( + args: ParsedArgs, + reporter: Reporter, + projectPath: string | undefined, +): Promise { + if (!projectPath) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library build needs the path of a library project.' }, + ExitCode.Usage, + ) + } + + const loaded = await loadProject(projectPath) + if (!loaded.success) { + return reporter.failure({ code: ErrorCode.ProjectNotFound, message: loaded.error }, ExitCode.NotFound) + } + for (const warning of loaded.project.warnings) reporter.progress(warning) + + const prepared = prepareLibraryData(loaded.project.data, reporter) + if ('error' in prepared) { + return reporter.failure({ code: ErrorCode.CompileFailed, message: prepared.error }, ExitCode.CompileFailed) + } + + reporter.progress(`Building library at ${loaded.project.projectPath}…`) + const result = await compileLibrary({ + projectPath: loaded.project.projectPath, + buildData: prepared.buildData, + verifyData: prepared.verifyData, + cleanBuild: boolFlag(args, 'clean'), + nativePous: prepared.nativePous, + onMessage: (message, level) => reporter.progress(` ${level === 'info' ? '' : `${level}: `}${message}`), + }) + + if (!result.success) { + return reporter.failure( + { code: ErrorCode.CompileFailed, message: result.error ?? 'Library build failed.' }, + ExitCode.CompileFailed, + ) + } + + return reporter.success( + { + ok: true, + library: result.libraryName, + stlibPath: result.stlibPath, + verification: result.verification ?? null, + }, + () => + [ + `Built ${result.libraryName ?? 'library'}`, + result.stlibPath ? ` ${result.stlibPath}` : '', + ` verification: ${describeVerification(result.verification)}`, + ] + .filter(Boolean) + .join('\n'), + ) +} + +/** + * The two `preprocessPous` passes the renderer's adapter runs, and the native + * POU list taken before them. + * + * The build pass keeps Python POUs as real code; the verification pass stubs + * them, because the simulator it compiles against has no interpreter. The + * native list has to be collected first: preprocessing lowers every native body + * to bridge ST and rewrites its language tag, leaving nothing to identify one + * by afterwards. + */ +function prepareLibraryData( + projectData: PLCProjectData, + reporter: Reporter, +): + | { buildData: PLCProjectData; verifyData: PLCProjectData; nativePous: ReturnType } + | { error: string } { + const nativePous = collectNativePous(projectData) + + // A library's own POU may hold a function block instance, so preprocessing + // needs the same pin sources a project build gets. + const fbSources = new LibraryManagerModule().loadAll().map((archive) => ({ + functionBlocks: archive.manifest.functionBlocks, + })) + + const buildPass = preprocessPous( + projectData, + false, + (level, message) => reporter.progress(` ${level === 'info' ? '' : `${level}: `}${message}`), + undefined, + fbSources, + ) + if (buildPass.validationFailed) { + return { error: buildPass.validationError ?? VALIDATION_FALLBACK } + } + + // Silent: the same project already logged its POUs on the build pass. + const verifyPass = preprocessPous(projectData, true, () => undefined, undefined, fbSources) + if (verifyPass.validationFailed) { + return { error: verifyPass.validationError ?? VALIDATION_FALLBACK } + } + + return { buildData: buildPass.projectData, verifyData: verifyPass.projectData, nativePous } +} + +const VALIDATION_FALLBACK = 'POU validation failed. Check C/C++ blocks for missing setup()/loop() functions.' + +/** + * Drive `CompilerModule.compileLibrary` over a plain channel. + * + * The protocol is the main process's: log messages arrive one at a time, then + * one message carrying `libraryBuildResult`, then the channel closes. The close + * is the only "done" signal, so the result is held until it arrives. + */ +function compileLibrary(options: { + projectPath: string + buildData: PLCProjectData + verifyData: PLCProjectData + cleanBuild: boolean + nativePous: ReturnType + onMessage: (message: string, level: 'info' | 'warning' | 'error') => void +}): Promise { + return new Promise((resolve) => { + let result: CompileLibraryResult | undefined + + const channel = createProgressChannel({ + onMessage: (message: unknown) => { + if (typeof message !== 'object' || message === null) return + const payload = message as Record + if (payload.libraryBuildResult) { + result = payload.libraryBuildResult as CompileLibraryResult + return + } + if (typeof payload.message === 'string') { + const level = payload.logLevel === 'warning' || payload.logLevel === 'error' ? payload.logLevel : 'info' + options.onMessage(payload.message, level) + } + }, + onClose: () => resolve(result ?? { success: false, error: 'Library build closed without a result.' }), + }) + + void new CompilerModule() + .compileLibrary( + // Positional, as the main process receives them over IPC: + // [projectPath, build-pass data, verify-pass data, cleanBuild, nativePous]. + // + // Shaped by `toIpcProjectData`, not passed as-is: the IPC form renames + // `configurations` to `configuration`, which the build pipeline reads. + [ + options.projectPath, + toIpcProjectData(options.buildData) as never, + toIpcProjectData(options.verifyData) as never, + options.cleanBuild, + options.nativePous as never, + ], + channel, + createHeadlessCompileBridge(null), + ) + .catch((error: unknown) => { + result = { success: false, error: error instanceof Error ? error.message : String(error) } + channel.close() + }) + }) +} + +function describeVerification(verification: CompileLibraryResult['verification']): string { + if (!verification) return 'not run' + return verification.success ? 'passed' : `failed — ${verification.message ?? 'see log'}` +} + +async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefined): Promise { + if (!stlibPath) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: 'library install needs the path of a .stlib file.' }, + ExitCode.Usage, + ) + } + + const result = await new LibraryManagerModule().installFromFile(stlibPath) + if (!result.success) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: result.error }, + ExitCode.TargetError, + ) + } + if (result.canceled) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: `Nothing installed from ${stlibPath}.` }, + ExitCode.TargetError, + ) + } + + return reporter.success( + { ok: true, library: result.name, version: result.version, origin: result.origin }, + () => `Installed ${result.name} ${result.version}`, + ) +} + +function runLibraryList(reporter: Reporter): CliResult { + const installed = new LibraryManagerModule().listInstalled() + return reporter.success( + { ok: true, libraries: installed }, + () => + installed.length === 0 + ? 'No libraries installed.' + : renderTable( + ['Name', 'Version', 'Origin'], + installed.map((library) => [library.name, library.version, library.origin]), + ), + ) +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a124a518d..7033ddc26 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -35,6 +35,7 @@ import { runCreate } from './commands/create' import { type DebugContext, runDebug } from './commands/debug' import { runDevices } from './commands/devices' import { runInstallCli } from './commands/install-cli' +import { runLibrary } from './commands/library' import { runDaemonFromStdin } from './daemon-entry' import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' import { createProcessReporter, Reporter } from './output' @@ -79,6 +80,9 @@ Usage openplc-cli create --from-json (fixture-friendly form) openplc-cli install-cli (put openplc-cli on your PATH) openplc-cli devices [--timeout ] + openplc-cli library build [--clean] + openplc-cli library install + openplc-cli library list openplc-cli compile [--target ] [--port ] [--clean] openplc-cli upload (--host
| --port ) [--target ] [--clean] [-y|--yes] openplc-cli debug open --target (--host
| --port ) [--upload-if-needed] @@ -201,6 +205,8 @@ async function dispatch(args: ParsedArgs, reporter: Reporter): Promise arg.endsWith('.js')) ?? __filename if (!script.endsWith('.js')) { throw new Error( `Cannot locate the CLI bundle to spawn a debug session (resolved "${script}"). ` + diff --git a/src/frontend/components/_atoms/string-length-menu-item/index.tsx b/src/frontend/components/_atoms/string-length-menu-item/index.tsx new file mode 100644 index 000000000..2d6089617 --- /dev/null +++ b/src/frontend/components/_atoms/string-length-menu-item/index.tsx @@ -0,0 +1,78 @@ +import * as PrimitiveDropdown from '@radix-ui/react-dropdown-menu' + +import { cn } from '../../../utils/cn' +import { MAX_STRING_LENGTH, parseStringLength } from '../../../utils/iec-types-registry' +import { InputWithRef } from '../input' + +type IStringLengthMenuItemProps = { + /** Canonical, upper-cased: STRING or WSTRING. */ + typeName: string + /** Current contents of the length box, as typed. */ + length: string + onLengthChange: (next: string) => void + /** Called with `STRING` or `STRING(23)` once the row is applied. */ + onApply: (declaredType: string) => void + /** Class for the label, so each menu keeps its own type scale. */ + labelClassName?: string +} + +/** + * A STRING or WSTRING row in a type menu, with its declared length beside it. + * An empty box selects the unqualified type. + */ +export const StringLengthMenuItem = ({ + typeName, + length, + onLengthChange, + onApply, + labelClassName = 'font-caption text-xs font-normal text-neutral-700 dark:text-neutral-500', +}: IStringLengthMenuItemProps) => { + const trimmed = length.trim() + const declaredType = trimmed === '' ? typeName : `${typeName}(${trimmed})` + const valid = trimmed === '' || parseStringLength(declaredType).valid + + return ( + { + // Refuse rather than silently apply the unqualified type. + if (!valid) { + event.preventDefault() + return + } + onApply(declaredType) + }} + className='flex h-8 w-full cursor-pointer items-center justify-center gap-1 py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + {typeName} + onLengthChange(e.target.value)} + // Radix routes typing to its typeahead and Space/Enter to selection, so + // digits reach the box only if the keystroke stops here. + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && valid) onApply(declaredType) + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + /> + + ) +} + +/** Seed the length box from a declaration already on the variable. */ +export const seedStringLengths = (declaredType: unknown): Record => { + const { base, length, valid } = parseStringLength(String(declaredType ?? '')) + return length !== undefined && valid ? { [base]: String(length) } : {} +} diff --git a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx index 7f75d4e41..571764b6b 100644 --- a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx +++ b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx @@ -3,7 +3,9 @@ import _ from 'lodash' import { useState } from 'react' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' +import { isLengthQualifiedType } from '../../../utils/iec-types-registry' import { DropdownSearchInput } from '../dropdown-search-input' +import { seedStringLengths, StringLengthMenuItem } from '../string-length-menu-item' type TypeDropdownSelectorProps = { value: string @@ -25,12 +27,13 @@ export const TypeDropdownSelector = ({ 'base-type': '', 'user-data-type': '', }) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) return (
- {value ? _.upperCase(value) : 'Select...'} + {value ? value.toUpperCase() : 'Select...'}
@@ -72,17 +75,31 @@ export const TypeDropdownSelector = ({ } /> {filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - onSelect(scope.definition as 'base-type' | 'user-data-type', value)} - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((entry) => + isLengthQualifiedType(entry) ? ( + + setStringLengths((prev) => ({ ...prev, [entry.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as 'base-type' | 'user-data-type', declaredType) + } + /> + ) : ( + onSelect(scope.definition as 'base-type' | 'user-data-type', entry)} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {entry.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx b/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx index 164a55e7d..16622ff20 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/selectable-cell.tsx @@ -8,8 +8,10 @@ import type { PLCStructureVariable } from '../../../../../../middleware/shared/p import { ArrowIcon } from '../../../../../assets/icons/interface/Arrow' import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' +import { isLengthQualifiedType } from '../../../../../utils/iec-types-registry' import { hasStringName, safeUpper } from '../../../../../utils/safe-upper' import { InputWithRef } from '../../../../_atoms/input' +import { seedStringLengths, StringLengthMenuItem } from '../../../../_atoms/string-length-menu-item' import { ArrayModal } from './elements/array-modal' type ISelectableCellProps = CellContext & { editable?: boolean } @@ -72,6 +74,7 @@ const SelectableTypeCell = ({ 'user-data-type': '', }) const [inputFilter, setInputFilter] = useState('') + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const variableName = table.options.data[index].name @@ -119,7 +122,7 @@ const SelectableTypeCell = ({ ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -163,17 +166,34 @@ const SelectableTypeCell = ({ />
{scope.values.length > 0 ? ( - scope.values.map((value) => ( - - onSelect(scope.definition as PLCStructureVariable['type']['definition'], value) - } - className='flex h-8 items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - {_.upperCase(value)} - - )) + scope.values.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCStructureVariable['type']['definition'], declaredType) + } + /> + ) : ( + + onSelect(scope.definition as PLCStructureVariable['type']['definition'], value) + } + className='flex h-8 items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx b/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx index fc9458d54..6207c3141 100644 --- a/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx @@ -10,10 +10,12 @@ import { DebuggerIcon } from '../../../assets/icons/interface/Debugger' import { useOpenPLCStore } from '../../../store' import { TypeChangeValidationResult, validateTypeChange } from '../../../store/slices/project/validation/type-change' import { cn } from '../../../utils/cn' +import { isLengthQualifiedType } from '../../../utils/iec-types-registry' import { hasStringName, safeUpper } from '../../../utils/safe-upper' import { propagateVariableTypeChange } from '../../../utils/variable-references' import { InputWithRef } from '../../_atoms/input' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../_atoms/select' +import { seedStringLengths, StringLengthMenuItem } from '../../_atoms/string-length-menu-item' import { TypeChangeModal } from '../type-change-modal' import { GlobalArrayModal } from './elements/array-modal' @@ -101,6 +103,7 @@ const SelectableTypeCell = ({ value: PLCVariable['type']['value'] } | null>(null) const [validationResult, setValidationResult] = useState(null) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const [variableFilters, setVariableFilters] = useState>({ 'base-type': '', @@ -221,11 +224,13 @@ const SelectableTypeCell = ({ })} > + {/* `toUpperCase`, not lodash `upperCase`: the latter splits on + punctuation, rendering STRING(15) as "STRING 15". */} {cellValue === null ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -272,19 +277,33 @@ const SelectableTypeCell = ({ />
{filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - - onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value) - } - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCGlobalVariable['type']['definition'], declaredType) + } + /> + ) : ( + + onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value) + } + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx index 3e51d1408..18de32bef 100644 --- a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx @@ -11,9 +11,11 @@ import { useOpenPLCStore } from '../../../store' import { TypeChangeValidationResult, validateTypeChange } from '../../../store/slices/project/validation/type-change' import { cn } from '../../../utils/cn' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' +import { isLengthQualifiedType } from '../../../utils/iec-types-registry' import { hasStringName, safeUpper } from '../../../utils/safe-upper' 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 { ArrayModal } from './elements/array-modal' @@ -140,6 +142,7 @@ const SelectableTypeCell = ({ value: PLCVariable['type']['value'] } | null>(null) const [validationResult, setValidationResult] = useState(null) + const [stringLengths, setStringLengths] = useState>(() => seedStringLengths(value)) const variableName = table.options.data[index].name const currentVariable = table.options.data[index] @@ -289,11 +292,13 @@ const SelectableTypeCell = ({ })} > + {/* `toUpperCase`, not lodash `upperCase`: the latter splits on + punctuation, rendering STRING(15) as "STRING 15". */} {cellValue === null ? '' : definition === 'array' || definition === 'derived' ? cellValue - : _.upperCase(cellValue as unknown as string)} + : (cellValue as unknown as string).toUpperCase()}
@@ -340,17 +345,31 @@ const SelectableTypeCell = ({ />
{filteredValues.length > 0 ? ( - filteredValues.map((value) => ( - onSelect(scope.definition as PLCVariable['type']['definition'], value)} - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - )) + filteredValues.map((value) => + isLengthQualifiedType(value) ? ( + + setStringLengths((prev) => ({ ...prev, [value.toUpperCase()]: next })) + } + onApply={(declaredType) => + onSelect(scope.definition as PLCVariable['type']['definition'], declaredType) + } + /> + ) : ( + onSelect(scope.definition as PLCVariable['type']['definition'], value)} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {value.toUpperCase()} + + + ), + ) ) : (
diff --git a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx index 3b74ebf1f..991b4edc0 100644 --- a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx +++ b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx @@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from 'react' import { baseTypeEnum } from '../../../../middleware/shared/ports/plc-schemas' import type { VariableClass } from '../../../../middleware/shared/ports/types' import type { CreateGraphicalVariableModalData } from '../../../store/slices/modal/types' +import { cn } from '../../../utils/cn' +import { isLengthQualifiedType, MAX_STRING_LENGTH, parseStringLength } from '../../../utils/iec-types-registry' import { getVariableRestrictionType } from '../../../utils/PLC/validate-variable-type' import { Label } from '../../_atoms/label' import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' @@ -39,6 +41,8 @@ const CreateGraphicalVariableModal = ({ const [name, setName] = useState(data.name) const [variableClass, setVariableClass] = useState('local') const [typeValue, setTypeValue] = useState(data.suggestedType.value) + // Empty means the unqualified type. + const [stringLength, setStringLength] = useState('') // A reused instance must never carry the previous pin's answers over. useEffect(() => { @@ -71,16 +75,20 @@ const CreateGraphicalVariableModal = ({ onClose() } + const lengthIsOffered = isLengthQualifiedType(typeValue) + const declaredType = lengthIsOffered && stringLength.trim() !== '' ? `${typeValue}(${stringLength.trim()})` : typeValue + const lengthIsValid = !lengthIsOffered || stringLength.trim() === '' || parseStringLength(declaredType).valid + const handleConfirm = () => { const trimmedName = name.trim() - if (!trimmedName) return + if (!trimmedName || !lengthIsValid) return const selected = typeOptions.find((option) => option.value === typeValue) data.onConfirm({ name: trimmedName, class: variableClass, // An option the list doesn't know can only come from the suggestion, so // keep the definition the editor derived for it. - type: selected ? { definition: selected.definition, value: selected.value } : data.suggestedType, + type: selected ? { definition: selected.definition, value: declaredType } : data.suggestedType, }) onClose() } @@ -154,6 +162,23 @@ const CreateGraphicalVariableModal = ({ ))}
+ + {lengthIsOffered && ( +
+ + setStringLength(event.target.value)} + className={cn(inputClass, !lengthIsValid && 'border-red-500 text-red-500')} + /> +
+ )}
diff --git a/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts b/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts index 89b04f4df..2e6e9cbdb 100644 --- a/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts +++ b/src/frontend/utils/PLC/__tests__/array-codegen-helpers.test.ts @@ -7,6 +7,7 @@ import { getArrayTotalElements, getVariableIECType, isArrayVariable, + isVariableLengthArray, mapBaseTypeToIEC, mapUserTypeToIEC, multiDimensionalContainerType, @@ -298,6 +299,57 @@ describe('the two native languages accept the same elementary types', () => { expect(generateStructMember(scalarOf(type))).toMatch(/^ {2}strucpp::IEC_\w+ \*V;\n$/) }) + // A declared length has to name the template directly: `IEC_STRING` is a fixed + // alias for `IECStringVar<254>`. The spelling must match what STruC++ emitted + // for the same declaration, because `_VARS` holds a pointer to the very + // member the function block declares — a disagreement here is an ABI mismatch, + // not a compile error. + describe('a declared string length on a native block pin', () => { + it('names the template rather than the 254-character alias', () => { + expect(mapBaseTypeToIEC('STRING(23)')).toBe('IECStringVar<23>') + expect(mapBaseTypeToIEC('WSTRING(8)')).toBe('IECWStringVar<8>') + }) + + it('reaches the struct member', () => { + expect(generateStructMember(scalarOf('STRING(23)'))).toBe(' strucpp::IECStringVar<23> *V;\n') + }) + + it('leaves an unqualified string on the alias', () => { + expect(mapBaseTypeToIEC('string')).toBe('IEC_STRING') + expect(generateStructMember(scalarOf('STRING'))).toBe(' strucpp::IEC_STRING *V;\n') + }) + + it('reads the bracket form too, since the parser normalises either way', () => { + expect(mapBaseTypeToIEC('STRING[23]')).toBe('IECStringVar<23>') + }) + + it('does not invent a template for a length nothing can carry', () => { + // Falls through to the ordinary spelling rule rather than emitting + // `IECStringVar<0>`, which would not compile. + expect(mapBaseTypeToIEC('STRING(0)')).not.toContain('IECStringVar<') + expect(mapBaseTypeToIEC('INT(4)')).not.toContain('IECStringVar<') + }) + + it('carries the element length of an array of strings', () => { + const arrayOfSized: PLCVariable = { + name: 'v', + class: 'input', + type: { + definition: 'array', + value: 'ARRAY[0..3] OF STRING(23)', + data: { + baseType: { definition: 'base-type', value: 'STRING(23)' }, + dimensions: [{ dimension: '0..3' }], + }, + }, + location: '', + documentation: '', + debug: false, + } + expect(getVariableIECType(arrayOfSized)).toBe('IECStringVar<23>') + }) + }) + it('accepts the long spellings IEC 61131-3 allows for the calendar types', () => { expect(mapBaseTypeToIEC('time_of_day')).toBe('IEC_TOD') expect(mapBaseTypeToIEC('date_and_time')).toBe('IEC_DT') @@ -329,6 +381,117 @@ describe('mapUserTypeToIEC', () => { }) }) +describe('generic types on a native block pin', () => { + // A native block may declare a VAR_INPUT with one of CODESYS's seven generic + // types. Every one is the same `IEC_ANY` descriptor at the ABI: the family + // constrains what the caller may pass, which the compiler checks at the call + // site, not what the block receives. + const genericPin = (typeName: string): PLCVariable => ({ + name: 'p', + class: 'input', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + debug: false, + }) + + it.each(['ANY', 'ANY_BIT', 'ANY_DATE', 'ANY_NUM', 'ANY_REAL', 'ANY_INT', 'ANY_STRING'])( + 'spells %s as the IEC_ANY descriptor', + (generic) => { + expect(mapUserTypeToIEC(generic)).toBe('IEC_ANY') + expect(generateStructMember(genericPin(generic))).toBe(' strucpp::IEC_ANY *P;\n') + }, + ) + + it('matches case-insensitively, as every other type spelling does', () => { + expect(mapUserTypeToIEC('any_int')).toBe('IEC_ANY') + }) + + it('leaves a user type called ANYTHING alone — only the exact names are generic', () => { + expect(mapUserTypeToIEC('ANYTHING', new Set(['ANYTHING']))).toBe('IEC_ANYTHING') + }) + + it('spells __SYSTEM.AnyType as the same descriptor', () => { + // The concrete structure a generic parameter carries. A native block may + // declare one to keep what it was passed. + expect(mapUserTypeToIEC('__SYSTEM.AnyType')).toBe('IEC_ANY') + expect(generateStructMember(genericPin('__SYSTEM.AnyType'))).toBe(' strucpp::IEC_ANY *P;\n') + }) +}) + +describe('variable-length arrays', () => { + // `ARRAY [*]` is legal as a function block's in-out variable. strucpp passes + // it as an ArrayView carrying the runtime bounds, so the struct holds a + // pointer to the view: there is no lower bound yet to offset by, and an + // element pointer would drop the only record of the length. + const vlaOfRank = ( + dimensions: string[], + baseType = 'INT', + baseDefinition: 'base-type' | 'user-data-type' = 'base-type', + ): PLCVariable => ({ + name: 'values', + class: 'inOut', + type: { + definition: 'array', + value: `ARRAY [${dimensions.join(', ')}] OF ${baseType}`, + data: { + baseType: { definition: baseDefinition, value: baseType }, + dimensions: dimensions.map((dimension) => ({ dimension })), + }, + }, + location: '', + documentation: '', + debug: false, + }) + + it('passes a 1-D VLA as a pointer to the view, not to the first element', () => { + expect(generateStructMember(vlaOfRank(['*']))).toBe(' strucpp::ArrayView1D *VALUES;\n') + }) + + it('passes a 2-D VLA as a view of its own rank', () => { + expect(generateStructMember(vlaOfRank(['*', '*'], 'REAL'))).toBe( + ' strucpp::ArrayView2D *VALUES;\n', + ) + }) + + it('spells a user-defined element type the way strucpp declares it', () => { + const named = new Set(['MOTOR']) + expect(generateStructMember(vlaOfRank(['*'], 'MOTOR', 'user-data-type'), named)).toBe( + ' strucpp::ArrayView1D *VALUES;\n', + ) + }) + + it('tolerates whitespace around the bound', () => { + expect(isVariableLengthArray(vlaOfRank([' * ']))).toBe(true) + }) + + it('leaves a fixed array on the element-pointer path', () => { + expect(isVariableLengthArray(vlaOfRank(['0..9']))).toBe(false) + expect(generateStructMember(vlaOfRank(['0..9']))).toBe(' strucpp::IEC_INT *VALUES;\n') + }) + + it('does not treat a partly-variable shape as a VLA, since IEC allows no such array', () => { + expect(isVariableLengthArray(vlaOfRank(['*', '0..3']))).toBe(false) + }) + + it('returns null past rank two, for which strucpp declares no ArrayView', () => { + expect(isVariableLengthArray(vlaOfRank(['*', '*', '*']))).toBe(false) + }) + + it('returns false for anything that is not an array', () => { + const scalar: PLCVariable = { + name: 'x', + class: 'input', + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + debug: false, + } + + expect(isVariableLengthArray(scalar)).toBe(false) + }) +}) + describe('multiDimensionalContainerType', () => { const arrayOfRank = ( dimensions: string[], diff --git a/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts b/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts new file mode 100644 index 000000000..47f11c333 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/generic-types-xml.test.ts @@ -0,0 +1,104 @@ +import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { baseTypeSchema } from '../../../../middleware/shared/ports/plc-schemas' +import { canonicalGenericType, isGenericType, PLCOPEN_GENERIC_TYPES } from '../generic-types' +import { generateStructMember } from '../array-codegen-helpers' +import { convertTypeToXml } from '../xml-generator/old-editor/type-xml' +import { parseTypeXml } from '../xml-parser/type-xml' + +/** + * Generic types across the PLCopen XML boundary. + * + * PLCopen TC6 v2.01 lists all ten in the `elementaryTypes` group, so each has + * an element of its own — ``, not ``, which would + * name a user-defined type that happens to be called ANY. + * + * Internally they are `user-data-type`: `base-type` values are validated + * against the elementary registry, and a generic is deliberately not in it. + */ +describe('generic types over PLCopen XML', () => { + it('recognises exactly the ten PLCopen names', () => { + expect([...PLCOPEN_GENERIC_TYPES]).toEqual([ + 'ANY', + 'ANY_DERIVED', + 'ANY_ELEMENTARY', + 'ANY_MAGNITUDE', + 'ANY_NUM', + 'ANY_REAL', + 'ANY_INT', + 'ANY_BIT', + 'ANY_STRING', + 'ANY_DATE', + ]) + }) + + it('does not mistake a user type whose name merely starts with ANY', () => { + expect(isGenericType('ANYTHING')).toBe(false) + expect(canonicalGenericType('ANYTHING')).toBeNull() + }) + + it('compares case-insensitively, as IEC identifiers do', () => { + expect(canonicalGenericType('any_int')).toBe('ANY_INT') + expect(canonicalGenericType(' Any ')).toBe('ANY') + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('reads <%s/> as a named type, not a base type', (generic) => { + // `base-type` would be a lie the project schema then rejects — see below. + expect(parseTypeXml({ [generic]: '' })).toEqual({ + definition: 'user-data-type', + value: generic, + }) + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('writes %s back as its own element', (generic) => { + expect(convertTypeToXml({ definition: 'user-data-type', value: generic })).toEqual({ + [generic]: '', + }) + }) + + it.each([...PLCOPEN_GENERIC_TYPES])('round-trips %s unchanged', (generic) => { + const parsed = parseTypeXml({ [generic]: '' }) + expect(convertTypeToXml(parsed)).toEqual({ [generic]: '' }) + }) + + it('is why a generic is not modelled as a base type', () => { + // The project's own schema validates `base-type` values against the + // elementary registry. Had the parser called a generic a base type, a + // project that merely mentions ANY would fail to save. + expect(baseTypeSchema.safeParse('ANY').success).toBe(false) + expect(baseTypeSchema.safeParse('INT').success).toBe(true) + }) + + it('still writes a real user-defined type as a derived reference', () => { + expect(convertTypeToXml({ definition: 'user-data-type', value: 'MOTOR' })).toEqual({ + derived: { '@name': 'MOTOR' }, + }) + }) + + it('still reads a derived reference as a user type', () => { + expect(parseTypeXml({ derived: { '@name': 'MOTOR' } })).toEqual({ + definition: 'derived', + value: 'MOTOR', + }) + }) + + it('leaves ordinary base types alone in both directions', () => { + expect(parseTypeXml({ INT: '' })).toEqual({ definition: 'base-type', value: 'INT' }) + expect(convertTypeToXml({ definition: 'base-type', value: 'INT' })).toEqual({ INT: '' }) + }) + + it('reaches the native bridge as the IEC_ANY descriptor', () => { + // The whole point of preserving the name: a native block declaring + // `P : ANY` must still get a descriptor pin after a save/load cycle. + const imported = parseTypeXml({ ANY: '' }) + const variable: PLCVariable = { + name: 'p', + class: 'input', + type: imported, + location: '', + documentation: '', + debug: false, + } + + expect(generateStructMember(variable)).toBe(' strucpp::IEC_ANY *P;\n') + }) +}) diff --git a/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts new file mode 100644 index 000000000..5a87612d8 --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts @@ -0,0 +1,83 @@ +import { parseTypeXml } from '../xml-parser/type-xml' +import { convertTypeToXml } from '../xml-generator/old-editor/type-xml' + +/** + * A declared string length across the PLCopen XML boundary. + * + * TC6 carries the length on the element as an attribute — + * ``. + * + * The round trip is what these tests are for: a dropped length goes unnoticed, + * since the project still opens and still compiles as the 254-character + * default, at 518 bytes per variable instead of 54. + */ +describe('a declared string length over PLCopen XML', () => { + it('writes the length as a TC6 attribute', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'STRING(23)' })).toEqual({ + string: { '@length': '23' }, + }) + }) + + it('writes WSTRING the same way', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'WSTRING(8)' })).toEqual({ + wstring: { '@length': '8' }, + }) + }) + + it('leaves an unqualified string as a bare element', () => { + expect(convertTypeToXml({ definition: 'base-type', value: 'STRING' })).toEqual({ string: '' }) + }) + + it('reads the attribute back into the declaration', () => { + expect(parseTypeXml({ string: { '@length': '23' } })).toEqual({ + definition: 'base-type', + value: 'STRING(23)', + }) + }) + + it.each([ + ['STRING(1)'], + ['STRING(23)'], + ['STRING(254)'], + ['WSTRING(8)'], + ['STRING'], + ['INT'], + ])('round-trips %s unchanged', (declared) => { + const xml = convertTypeToXml({ definition: 'base-type', value: declared }) + expect(parseTypeXml(xml)).toEqual({ definition: 'base-type', value: declared }) + }) + + it('round-trips an ARRAY of sized strings, element length included', () => { + const type = { + definition: 'array' as const, + value: 'ARRAY[0..3] OF STRING(23)', + data: { + baseType: { definition: 'base-type' as const, value: 'STRING(23)' }, + dimensions: [{ dimension: '0..3' }], + }, + } + const xml = convertTypeToXml(type) + expect(parseTypeXml(xml).data?.baseType).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + }) + + // An importer meeting a foreign file must not turn it into a type nothing + // downstream recognises, so an unusable attribute degrades to the plain type + // rather than failing the load. + it.each([ + ['zero', '0'], + ['past the implementation maximum', '999'], + ['not a number', 'lots'], + ])('ignores a length that is %s', (_label, raw) => { + expect(parseTypeXml({ string: { '@length': raw } })).toEqual({ + definition: 'base-type', + value: 'STRING', + }) + }) + + it('ignores a length on an element that cannot carry one', () => { + expect(parseTypeXml({ INT: { '@length': '4' } })).toEqual({ + definition: 'base-type', + value: 'INT', + }) + }) +}) diff --git a/src/frontend/utils/PLC/array-codegen-helpers.ts b/src/frontend/utils/PLC/array-codegen-helpers.ts index 88986316d..017f70dae 100644 --- a/src/frontend/utils/PLC/array-codegen-helpers.ts +++ b/src/frontend/utils/PLC/array-codegen-helpers.ts @@ -1,4 +1,5 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' +import { parseStringLength } from '../iec-types-registry' import { parseDimensionRange } from './dimension-range' const BASE_TYPE_TO_IEC: Record = { @@ -87,12 +88,60 @@ const getArrayBaseTypeValue = (variable: PLCVariable): string => { * Without `userTypeNames` the bare name is returned, which is the correct * answer for a function block and the historical behaviour for everything else. */ +/** + * The generic type names a native block may declare on a VAR_INPUT, and the one + * runtime type they all resolve to. + * + * All seven share a representation — the `IEC_ANY` descriptor + * `{ typeclass, pvalue, diSize }`. The family constrains what the caller may + * pass, which the compiler checks at the call site, not what the block receives. + */ +const GENERIC_TYPE_TO_IEC: Record = { + // Not a generic: the descriptor a generic carries, declarable in its own + // right so a block can keep what it was handed. Same runtime type. + '__SYSTEM.ANYTYPE': 'IEC_ANY', + + ANY: 'IEC_ANY', + ANY_BIT: 'IEC_ANY', + ANY_DATE: 'IEC_ANY', + ANY_NUM: 'IEC_ANY', + ANY_REAL: 'IEC_ANY', + ANY_INT: 'IEC_ANY', + ANY_STRING: 'IEC_ANY', +} + +/** + * Whether a pin's declared type is a generic (or the descriptor it carries), + * and so resolves to the runtime's `IEC_ANY` rather than to a project type. + */ +const isDescriptorPinType = (typeName: string): boolean => + GENERIC_TYPE_TO_IEC[typeName.toUpperCase()] !== undefined + const mapUserTypeToIEC = (typeName: string, userTypeNames?: ReadonlySet): string => { const upper = typeName.toUpperCase() + const generic = GENERIC_TYPE_TO_IEC[upper] + if (generic) return generic return userTypeNames?.has(upper) ? `IEC_${upper}` : upper } +/** + * strucpp wrapper for a length-qualified string, or `null` for anything else. + * + * `IEC_STRING` / `IEC_WSTRING` are fixed aliases for the 254-character + * wrappers, so a declared length names the template directly. Must match what + * STruC++ emits for the same declaration (`IECStringVar<23>`): `_VARS` + * points at the member the function block declares, so a mismatch is an ABI + * bug, not a compile error. + */ +const sizedStringIECType = (baseType: string): string | null => { + const { base, length, valid } = parseStringLength(baseType) + if (length === undefined || !valid) return null + return base === 'WSTRING' ? `IECWStringVar<${length}>` : `IECStringVar<${length}>` +} + const mapBaseTypeToIEC = (baseType: string, userTypeNames?: ReadonlySet): string => { + const sized = sizedStringIECType(baseType) + if (sized) return sized const elementary = BASE_TYPE_TO_IEC[baseType.toLowerCase()] if (elementary) return elementary // Not elementary: an array of a user-defined type, or a type the map does not @@ -164,6 +213,39 @@ const multiDimensionalContainerType = (variable: PLCVariable, userTypeNames?: Re return `Array${dimensions.length}D` } +/** The bound a variable-length array dimension carries. */ +const VARIABLE_LENGTH_BOUND = '*' + +/** + * strucpp view type for a variable-length array, or `null` for anything else. + * + * A VLA pin (`ARRAY [*] OF INT`) has no bounds until it is called, so it cannot + * be a pointer to its first element: nothing would carry the element count or + * the lower bound. strucpp passes `ArrayViewD` — data pointer plus runtime + * bounds — reached through `lower_bound()` / `upper_bound()` / `at()`, so the + * struct holds a pointer to the view itself. + * + * Rank one and two only: the runtime declares `ArrayView1D` and `ArrayView2D` + * and nothing beyond. A mixed shape like `ARRAY [*, 0..3]` is not legal and + * falls to the fixed-array path. + */ +const variableLengthViewType = (variable: PLCVariable, userTypeNames?: ReadonlySet): string | null => { + if (variable.type.definition !== 'array' || !variable.type.data) return null + + const dimensions = variable.type.data.dimensions + if (dimensions.length < 1 || dimensions.length > 2) return null + if (!dimensions.every((dimension) => dimension.dimension.trim() === VARIABLE_LENGTH_BOUND)) return null + + const elementType = mapBaseTypeToIEC(variable.type.data.baseType.value, userTypeNames) + return `ArrayView${dimensions.length}D` +} + +/** + * Whether a variable is a variable-length array, and so is passed as a view + * rather than as a pointer to its first element. + */ +const isVariableLengthArray = (variable: PLCVariable): boolean => variableLengthViewType(variable) !== null + /** * Generate a C struct member declaration for a variable. * Both scalars and arrays use pointers: @@ -188,6 +270,9 @@ const multiDimensionalContainerType = (variable: PLCVariable, userTypeNames?: Re */ const generateStructMember = (variable: PLCVariable, userTypeNames?: ReadonlySet): string => { const name = variable.name.toUpperCase() + const variableLength = variableLengthViewType(variable, userTypeNames) + if (variableLength) return ` strucpp::${variableLength} *${name};\n` + const multiDimensional = multiDimensionalContainerType(variable, userTypeNames) if (multiDimensional) return ` strucpp::${multiDimensional} *${name};\n` @@ -202,6 +287,8 @@ export { getArrayTotalElements, getVariableIECType, isArrayVariable, + isDescriptorPinType, + isVariableLengthArray, mapBaseTypeToIEC, mapUserTypeToIEC, multiDimensionalContainerType, diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts index 3f9113db1..f59162394 100644 --- a/src/frontend/utils/PLC/data-type-text-parser.ts +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -37,11 +37,11 @@ const enumRegex = /^(?\w+)\s*:\s*\((?[^)]*)\)\s*(?::=\s*(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*)\s*(?::=\s*(?[^;]+?))?\s*;$/i + /^(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*(?:\s*[([]\s*\d+\s*[)\]])?)\s*(?::=\s*(?[^;]+?))?\s*;$/i // FieldName : Type := Initial ; (* documentation *) const fieldRegex = - /^(?\w+)\s*:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^(?\w+)\s*:\s*(?[\w\s[\](),.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ const guessErrorReason = (line: string): string => { if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' diff --git a/src/frontend/utils/PLC/generic-types.ts b/src/frontend/utils/PLC/generic-types.ts new file mode 100644 index 000000000..2925463a5 --- /dev/null +++ b/src/frontend/utils/PLC/generic-types.ts @@ -0,0 +1,46 @@ +/** + * Generic type names, and how they cross the PLCopen XML boundary. + * + * PLCopen TC6 v2.01 puts these in the `elementaryTypes` group, so `` and + * `` are element tags of their own, not ``. + * + * The editor models them as `user-data-type`, not `base-type`: `base-type` + * values are validated against `baseTypeSchema`, the elementary registry, and a + * generic has no width, no wire format and nothing to debug. + */ + +/** + * The ten names PLCopen TC6 v2.01 defines, in schema order. + * + * Three more than a POU may be declared with: `ANY_DERIVED`, `ANY_ELEMENTARY` + * and `ANY_MAGNITUDE` classify types without being declarable. They still round + * trip, and the compiler is what refuses them, naming the type. + */ +const PLCOPEN_GENERIC_TYPES = [ + 'ANY', + 'ANY_DERIVED', + 'ANY_ELEMENTARY', + 'ANY_MAGNITUDE', + 'ANY_NUM', + 'ANY_REAL', + 'ANY_INT', + 'ANY_BIT', + 'ANY_STRING', + 'ANY_DATE', +] as const + +const GENERIC_TYPE_SET: ReadonlySet = new Set(PLCOPEN_GENERIC_TYPES) + +/** Whether a type name is one of the PLCopen generic types. Case-insensitive. */ +const isGenericType = (typeName: string): boolean => GENERIC_TYPE_SET.has(typeName.trim().toUpperCase()) + +/** + * The canonical spelling of a generic type name, or `null` if it is not one. + * PLCopen writes these upper-case, so the tag and the name are the same text. + */ +const canonicalGenericType = (typeName: string): string | null => { + const upper = typeName.trim().toUpperCase() + return GENERIC_TYPE_SET.has(upper) ? upper : null +} + +export { canonicalGenericType, isGenericType, PLCOPEN_GENERIC_TYPES } diff --git a/src/frontend/utils/PLC/global-variable-list-text-parser.ts b/src/frontend/utils/PLC/global-variable-list-text-parser.ts index 5cbdbba7d..1dee69f70 100644 --- a/src/frontend/utils/PLC/global-variable-list-text-parser.ts +++ b/src/frontend/utils/PLC/global-variable-list-text-parser.ts @@ -56,7 +56,7 @@ const commentOnlyRegex = /^\(\*[\s\S]*\*\)$/ // CODESYS converter writes a GVL declaration in — expecting it after the type made // this parser reject the very declarations the importer produces. const declarationRegex = - /^(?\w+(?:\s*,\s*\w+)*)\s*(?:AT\s+(?%[\w.]+)\s*)?:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/i + /^(?\w+(?:\s*,\s*\w+)*)\s*(?:AT\s+(?%[\w.]+)\s*)?:\s*(?[\w\s[\](),.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/i function buildVariableType(typeStr: string): PLCVariableType | null { const arrayType = parseArrayType(typeStr) diff --git a/src/frontend/utils/PLC/pou-signature-serializer.ts b/src/frontend/utils/PLC/pou-signature-serializer.ts index 0661b6ba4..b8515727f 100644 --- a/src/frontend/utils/PLC/pou-signature-serializer.ts +++ b/src/frontend/utils/PLC/pou-signature-serializer.ts @@ -77,6 +77,11 @@ function buildDeclarationLine(pou: PLCPou): string { if (pou.pouType === 'function' && pou.interface?.returnType) { return `${startKeyword} ${pou.name} : ${pou.interface.returnType}` } + // The LSP needs the base too: without it a derived block appears to have only + // its own pins and methods, so every inherited one reads as undefined. + if (pou.interface?.extends) { + return `${startKeyword} ${pou.name} EXTENDS ${pou.interface.extends}` + } return `${startKeyword} ${pou.name}` } diff --git a/src/frontend/utils/PLC/pou-text-parser.ts b/src/frontend/utils/PLC/pou-text-parser.ts index d3178a45b..9731c0be9 100644 --- a/src/frontend/utils/PLC/pou-text-parser.ts +++ b/src/frontend/utils/PLC/pou-text-parser.ts @@ -91,7 +91,13 @@ export const parseTextualPouFromString = (content: string, language: string, typ throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -100,6 +106,7 @@ export const parseTextualPouFromString = (content: string, language: string, typ const pouName = declarationMatch[2] const returnType = declarationMatch[3] // Only present for functions + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -151,6 +158,7 @@ export const parseTextualPouFromString = (content: string, language: string, typ pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -190,7 +198,13 @@ export const parseHybridPouFromString = (content: string, language: string, type throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -199,6 +213,7 @@ export const parseHybridPouFromString = (content: string, language: string, type const pouName = declarationMatch[2] const returnType = declarationMatch[3] // Only present for functions + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -248,6 +263,7 @@ export const parseHybridPouFromString = (content: string, language: string, type pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { @@ -288,7 +304,13 @@ export const parseGraphicalPouFromString = (content: string, language: string, t throw new Error(formatParseError(`Unsupported POU type: ${type}`)) } - const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + // Captures the EXTENDS clause: anything between the POU name and the first + // VAR block fell outside `declarationMatch[0]` and was dropped, so a derived + // block reached the compiler with no base. + const declarationRegex = new RegExp( + `^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?(?:\\s+EXTENDS\\s+(\\w+))?`, + 'i', + ) const declarationMatch = remainingContent.match(declarationRegex) if (!declarationMatch) { @@ -297,6 +319,7 @@ export const parseGraphicalPouFromString = (content: string, language: string, t const pouName = declarationMatch[2] const returnType = declarationMatch[3] + const baseBlock = declarationMatch[4] // Only present with EXTENDS if (type === 'function' && !returnType) { throw new Error(formatParseError(`Function ${pouName} must have a return type`)) @@ -358,6 +381,7 @@ export const parseGraphicalPouFromString = (content: string, language: string, t pouType: type as PouType, interface: { ...(type === 'function' ? { returnType: resolvedReturnType } : {}), + ...(baseBlock ? { extends: baseBlock } : {}), variables, }, body: { diff --git a/src/frontend/utils/PLC/pou-text-serializer.ts b/src/frontend/utils/PLC/pou-text-serializer.ts index 2b1c31a7c..59c6b38c5 100644 --- a/src/frontend/utils/PLC/pou-text-serializer.ts +++ b/src/frontend/utils/PLC/pou-text-serializer.ts @@ -17,6 +17,10 @@ const buildDeclaration = (pou: SerializablePou): string => { if (pou.pouType === 'function' && pou.interface?.returnType) { return `${startKeyword} ${pou.name} : ${pou.interface.returnType}\n` } + // The base must survive the round trip, or saving a derived POU un-derives it. + if (pou.interface?.extends) { + return `${startKeyword} ${pou.name} EXTENDS ${pou.interface.extends}\n` + } return `${startKeyword} ${pou.name}\n` } diff --git a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts index cfc8a18a9..7a4901dab 100644 --- a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts +++ b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts @@ -1,4 +1,4 @@ -import { lookupBaseType } from '../../iec-types-registry' +import { lookupBaseType, parseStringLength } from '../../iec-types-registry' /** * Pick the PLCopen TC6 0201 XML element tag for a base type value. @@ -33,3 +33,16 @@ export const baseTypeTag = (value: string): string => { export const isPlcopenStandardType = (value: string): boolean => { return lookupBaseType(value)?.xml.plcopenStandard ?? false } + +/** + * Body of a base-type element: the empty string for everything, or the TC6 + * `length` attribute for a declared string length. + * + * TC6 carries the length as an attribute, so `STRING(23)` is + * ``. Without it a save/load round trip drops the + * declaration back to the 254-character default. + */ +export const baseTypeElementBody = (value: string): '' | { '@length': string } => { + const { length, valid } = parseStringLength(value) + return length !== undefined && valid ? { '@length': String(length) } : '' +} diff --git a/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts index 1f67fb6fb..11279272b 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/data-type-xml.ts @@ -1,6 +1,7 @@ import { PLCDataType } from '@root/middleware/shared/ports/open-plc-types' import { BaseXml } from '@root/middleware/shared/ports/xml-types/codesys' +import { isGenericType } from '../../generic-types' import { baseTypeTag } from '../base-type-tag' const parseDimensions = (dimensions: Array<{ dimension: string }>) => { @@ -78,7 +79,9 @@ export const codeSysParseDataTypesToXML = (xml: BaseXml, dataTypes: PLCDataType[ return { '@name': variable.name, type: { - [baseTypeTag(variable.type.value)]: '', + [isGenericType(variable.type.value) + ? variable.type.value.trim().toUpperCase() + : baseTypeTag(variable.type.value)]: '', }, initialValue: variable.initialValue?.simpleValue.value ? { diff --git a/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts index e97625fb7..f4a7c4095 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts @@ -6,6 +6,7 @@ import { BaseXml } from '@root/middleware/shared/ports/xml-types/codesys' import { InterfaceXML } from '@root/middleware/shared/ports/xml-types/codesys/pous/interface/interface-diagram' import { VariableXML } from '@root/middleware/shared/ports/xml-types/codesys/variable/variable-diagram' +import { isGenericType } from '../../generic-types' import { baseTypeTag } from '../base-type-tag' import { fbdToXml } from './language/fbd-xml' import { ilToXML } from './language/il-xml' @@ -42,6 +43,10 @@ export const codeSysParseInterface = (pou: PLCPou) => { }, }, } + } else if (isGenericType(variable.type.value)) { + // A generic is its own element in the schema's elementaryTypes group; + // `` would name a user type called ANY instead. + vType = { [variable.type.value.trim().toUpperCase()]: '' } } else if (variable.type.definition === 'derived' || variable.type.definition === 'user-data-type') { vType = { derived: { @@ -81,7 +86,11 @@ export const codeSysParseInterface = (pou: PLCPou) => { if (!xml.returnType) xml.returnType = {} const isBaseType = baseTypes.includes(returnType) - xml.returnType = isBaseType ? { [baseTypeTag(returnType)]: '' } : { ['derived']: { '@name': returnType } } + xml.returnType = isGenericType(returnType) + ? { [returnType.trim().toUpperCase()]: '' } + : isBaseType + ? { [baseTypeTag(returnType)]: '' } + : { ['derived']: { '@name': returnType } } } switch (variable.class) { diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts index 33795c8de..29a113805 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/type-xml.ts @@ -1,6 +1,7 @@ import { PLCVariable } from '@root/middleware/shared/ports/open-plc-types' -import { baseTypeTag } from '../base-type-tag' +import { isGenericType } from '../../generic-types' +import { baseTypeElementBody, baseTypeTag } from '../base-type-tag' type VariableType = PLCVariable['type'] @@ -24,12 +25,20 @@ export const convertTypeToXml = (type: VariableType): Record => }), baseType: { [baseTypeKey]: - type.data!.baseType.definition === 'user-data-type' ? { '@name': type.data!.baseType.value } : '', + type.data!.baseType.definition === 'user-data-type' + ? { '@name': type.data!.baseType.value } + : baseTypeElementBody(type.data!.baseType.value), }, }, } } + // A generic has an element of its own in the schema's elementaryTypes group. + // `` would name a user-defined type called ANY instead. + if (isGenericType(type.value)) { + return { [type.value.trim().toUpperCase()]: '' } + } + if (type.definition === 'derived' || type.definition === 'user-data-type') { return { derived: { @@ -38,8 +47,9 @@ export const convertTypeToXml = (type: VariableType): Record => } } - // base-type + // base-type. A declared string length rides on the element as TC6's `length` + // attribute — `` — so it survives a save/load round trip. return { - [baseTypeTag(type.value)]: '', + [baseTypeTag(type.value)]: baseTypeElementBody(type.value), } } diff --git a/src/frontend/utils/PLC/xml-parser/type-xml.ts b/src/frontend/utils/PLC/xml-parser/type-xml.ts index 225c7d0e7..8995e36e2 100644 --- a/src/frontend/utils/PLC/xml-parser/type-xml.ts +++ b/src/frontend/utils/PLC/xml-parser/type-xml.ts @@ -1,5 +1,6 @@ import type { PLCVariableType } from '../../../../middleware/shared/ports/types' -import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { lookupBaseTypeByXmlElement, parseStringLength } from '../../iec-types-registry' +import { canonicalGenericType } from '../generic-types' import { asArray, asRecord, asString } from './xml-node' type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string } @@ -8,6 +9,24 @@ type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string // PLCopen ``/`` element has exactly one child key, which is // either a recognised IEC base-type tag, `derived` (user type/FB reference), // or `array` (nested dimensions + element base type). +/** + * Fold a TC6 `length` attribute into the type name, so `` + * becomes `STRING(23)`. + * + * A length this implementation cannot carry degrades to the unqualified type + * rather than failing the load. + */ +function withDeclaredLength(name: string, elementXml: unknown): string { + const raw = asRecord(elementXml)['@length'] + if (raw === undefined) return name + const candidate = `${name}(${asString(raw).trim()})` + // Both halves matter: `parseStringLength` reports `valid: true` with no + // `length` for an unqualified name, so checking `valid` alone would admit + // `` as a type named "STRING(lots)". + const { length, valid } = parseStringLength(candidate) + return length !== undefined && valid ? candidate : name +} + function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { const rec = asRecord(baseTypeXml) if ('derived' in rec) { @@ -15,7 +34,10 @@ function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { } const tag = Object.keys(rec)[0] if (tag === undefined) throw new Error('Type element has no recognizable base type') - return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } + const generic = canonicalGenericType(tag) + if (generic) return { definition: 'user-data-type', value: generic } + const name = lookupBaseTypeByXmlElement(tag)?.name ?? tag + return { definition: 'base-type', value: withDeclaredLength(name, rec[tag]) } } function parseDimensionsXml(dimensionXml: unknown): Array<{ dimension: string }> { @@ -42,7 +64,13 @@ export function parseTypeXml(typeXml: unknown): PLCVariableType { const tag = Object.keys(type)[0] if (tag === undefined) throw new Error('Variable type element is empty') - return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } + // A generic is an elementaryTypes element in the schema, but not a base type + // here — `base-type` values are validated against the elementary registry, + // which a generic is deliberately absent from. See `generic-types.ts`. + const generic = canonicalGenericType(tag) + if (generic) return { definition: 'user-data-type', value: generic } + const name = lookupBaseTypeByXmlElement(tag)?.name ?? tag + return { definition: 'base-type', value: withDeclaredLength(name, type[tag]) } } export { parseBaseTypeLeaf, parseDimensionsXml } diff --git a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts index 065c249b8..4128cd8bf 100644 --- a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts +++ b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts @@ -293,27 +293,65 @@ describe('parseIecStringToVariables', () => { }) describe('a declared string length', () => { - // Legal IEC and legal CODESYS, and STruC++ does not accept it. Left alone it - // was not even recognised as a string: it became a user data type literally - // named "STRING[20]", emitted verbatim into the generated ST, where the - // compiler failed with `Expected Semicolon, found [` on a line the user - // never wrote. - it('is refused, naming the type and what to use instead', () => { - expect(() => parseIecStringToVariables('VAR\n s : STRING[20];\nEND_VAR')).toThrow( - /A declared length is not supported on STRING — use plain STRING, which carries up to 126 characters/, - ) + // STruC++ emits `IECStringVar<23>` — 54 bytes against 518 for the + // unqualified type. + it('is accepted in the standard parenthesised form', () => { + const result = parseIecStringToVariables('VAR\n s : STRING(23);\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + }) + + it('accepts WSTRING too', () => { + const result = parseIecStringToVariables('VAR\n s : WSTRING(8);\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'WSTRING(8)' }) + }) + + // Square brackets are long-established in the field, so they are read and + // normalised rather than refused — a project stores one spelling whichever + // the user typed. + it('normalises the bracket form to the standard one', () => { + const result = parseIecStringToVariables('VAR\n s : STRING[20];\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(20)' }) + }) + + it('is read whatever the spacing and case', () => { + const result = parseIecStringToVariables('VAR\n s : string ( 12 );\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'STRING(12)' }) }) - it('is refused for WSTRING too', () => { - expect(() => parseIecStringToVariables('VAR\n s : WSTRING[8];\nEND_VAR')).toThrow(/not supported on WSTRING/) + it('accepts the bounds themselves', () => { + const one = parseIecStringToVariables('VAR\n s : STRING(1);\nEND_VAR') + const max = parseIecStringToVariables('VAR\n s : STRING(254);\nEND_VAR') + + expect(one[0].type.value).toBe('STRING(1)') + expect(max[0].type.value).toBe('STRING(254)') }) - it('is refused whatever the spacing and case', () => { - expect(() => parseIecStringToVariables('VAR\n s : string [ 12 ];\nEND_VAR')).toThrow(/not supported on STRING/) + // Writing the shape is what commits you to a length. Each of these would + // otherwise become a user data type literally named "STRING[]", persisted + // and emitted verbatim into the generated ST, where the compiler fails at a + // column the user never wrote. + it.each([ + ['an empty length', 'STRING[]'], + ['a non-numeric length', 'STRING(abc)'], + ['zero', 'STRING(0)'], + ['past the implementation maximum', 'STRING(255)'], + ])('is refused for %s, rather than becoming a stranger type', (_label, declared) => { + expect(() => parseIecStringToVariables(`VAR\n s : ${declared};\nEND_VAR`)).toThrow(/takes a length from 1 to 254/) }) - it('is refused when the length is empty, rather than becoming a stranger type', () => { - expect(() => parseIecStringToVariables('VAR\n s : STRING[];\nEND_VAR')).toThrow(/not supported on STRING/) + // The element form needs `parseArrayType` to admit a length after `OF`; + // without it this matched nothing and fell through to the compiler, which + // reported `Expected Semicolon, found [` at a column the user never wrote. + it('carries a length on an ARRAY element type', () => { + const result = parseIecStringToVariables('VAR\n tags : ARRAY[0..3] OF STRING(23);\nEND_VAR') + + expect(result[0].type.definition).toBe('array') + expect(result[0].type.data?.baseType).toEqual({ definition: 'base-type', value: 'STRING(23)' }) + expect(result[0].type.data?.dimensions).toEqual([{ dimension: '0..3' }]) }) it('still accepts a plain STRING', () => { @@ -611,4 +649,51 @@ describe('parseIecStringToVariables', () => { expect(result[0].class).toBe('input') }) + + // ---- variable-length arrays ---- + + it('parses a variable-length array, the bound a VLA carries', () => { + // `ARRAY [*] OF INT`, legal as a function block's in-out variable. The type + // group excluded `*`, so the line matched nothing and the POU loaded with no + // variables at all. + const result = parseIecStringToVariables('VAR_IN_OUT\n values : ARRAY [*] OF INT;\nEND_VAR') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('values') + expect(result[0].class).toBe('inOut') + expect(result[0].type).toEqual({ + definition: 'array', + value: 'ARRAY [*] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '*' }], + }, + }) + }) + + it('parses a two-dimensional variable-length array', () => { + const result = parseIecStringToVariables('VAR_IN_OUT\n grid : ARRAY [*,*] OF REAL;\nEND_VAR') + + expect(result[0].type.data?.dimensions).toEqual([{ dimension: '*' }, { dimension: '*' }]) + }) + + it('does not read an empty bound as a variable-length one', () => { + // `*` is a bound; nothing is not. `ARRAY []` still declines to parse as an + // array — it carries no dimension for the array path to read — so it lands + // as a named type rather than being mistaken for `ARRAY [*]`. + const result = parseIecStringToVariables('VAR\n bad : ARRAY [] OF INT;\nEND_VAR') + + expect(result[0].type.definition).not.toBe('array') + expect(result[0].type.value).toBe('ARRAY [] OF INT') + }) + + it('does not mistake a comment for a type', () => { + // `(` stays outside the type character class, so widening it for `*` could + // not let a `(*` comment be read as one. + const result = parseIecStringToVariables('VAR\n count : INT; (* how many *)\nEND_VAR') + + expect(result[0].type).toEqual({ definition: 'base-type', value: 'INT' }) + expect(result[0].documentation).toBe('how many') + }) + }) diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 329453b4b..775bbad67 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -2,8 +2,11 @@ import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, + isLengthQualifiedType, lookupBaseType, lookupBaseTypeByXmlElement, + MAX_STRING_LENGTH, + parseStringLength, } from '../iec-types-registry' describe('iec-types-registry', () => { @@ -134,4 +137,88 @@ describe('iec-types-registry', () => { }) }) }) + + // The length is parenthesised in the declaration, bounded by the capacity of + // the unqualified type. + describe('a declared string length', () => { + it('splits the standard parenthesised form', () => { + expect(parseStringLength('STRING(23)')).toEqual({ base: 'STRING', length: 23, valid: true }) + }) + + it('accepts the bracket form and reports the same base', () => { + expect(parseStringLength('STRING[23]')).toEqual({ base: 'STRING', length: 23, valid: true }) + }) + + it('normalises case and whitespace, as IEC identifiers are case-insensitive', () => { + expect(parseStringLength(' wstring ( 8 ) ')).toEqual({ base: 'WSTRING', length: 8, valid: true }) + }) + + it('reports no length for an unqualified name, without calling it invalid', () => { + // `valid` describes what was written, so "nothing was written" is not an + // error — it is how a caller tells a plain STRING from STRING(0). + expect(parseStringLength('STRING')).toEqual({ base: 'STRING', valid: true }) + expect(parseStringLength('INT')).toEqual({ base: 'INT', valid: true }) + }) + + it.each([ + ['zero', 'STRING(0)'], + ['past the implementation maximum', `STRING(${MAX_STRING_LENGTH + 1})`], + ])('rejects %s', (_label, declared) => { + expect(parseStringLength(declared).valid).toBe(false) + }) + + it('rejects a length on a type that cannot carry one', () => { + // `INT(4)` is not a narrower integer — only STRING and WSTRING are + // length-qualified, so this must not resolve to the INT metadata. + expect(parseStringLength('INT(4)').valid).toBe(false) + expect(lookupBaseType('INT(4)')).toBeUndefined() + }) + + it('leaves an ARRAY declaration alone', () => { + // The regex must anchor on a bare identifier plus a length, or an inline + // array would be mistaken for one. + expect(parseStringLength('ARRAY[0..3] OF INT').length).toBeUndefined() + }) + }) + + describe('lookupBaseType with a declared length', () => { + it('resolves to the STRING metadata, so every existing caller keeps working', () => { + // ~31 call sites across 12 files — baseTypeTag, the XML emitters, the + // debugger decoder, the force encoder — ask this one function what a type + // is. Stripping the length here is what keeps them all unchanged. + expect(lookupBaseType('STRING(23)')).toBe(lookupBaseType('STRING')) + expect(lookupBaseType('WSTRING(8)')?.name).toBe('WSTRING') + }) + + it('keeps the XML element name, which is what the emitters need', () => { + expect(lookupBaseType('STRING(23)')?.xml.elementName).toBe('string') + }) + + it('returns undefined for a length it cannot carry', () => { + expect(lookupBaseType('STRING(0)')).toBeUndefined() + expect(lookupBaseType('STRING(999)')).toBeUndefined() + }) + + it('is reflected by isBaseTypeName', () => { + expect(isBaseTypeName('STRING(23)')).toBe(true) + expect(isBaseTypeName('STRING(0)')).toBe(false) + }) + }) + + // The type dropdown asks this to decide which rows get a length box. + describe('isLengthQualifiedType', () => { + it('is true for STRING and WSTRING only', () => { + expect(isLengthQualifiedType('STRING')).toBe(true) + expect(isLengthQualifiedType('WSTRING')).toBe(true) + for (const name of ['INT', 'DINT', 'REAL', 'BOOL', 'TIME', 'ARRAY']) { + expect(isLengthQualifiedType(name)).toBe(false) + } + }) + + it('normalises case and surrounding whitespace, like the rest of the registry', () => { + expect(isLengthQualifiedType(' string ')).toBe(true) + expect(isLengthQualifiedType('WString')).toBe(true) + }) + }) + }) diff --git a/src/frontend/utils/__tests__/pou-helpers.test.ts b/src/frontend/utils/__tests__/pou-helpers.test.ts index a33829b8b..6910513dc 100644 --- a/src/frontend/utils/__tests__/pou-helpers.test.ts +++ b/src/frontend/utils/__tests__/pou-helpers.test.ts @@ -1,6 +1,7 @@ import type { PLCDataType, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { + findArrayDataType, findFunctionBlockExternalVariables, findFunctionBlockVariables, findLeafVariables, @@ -204,6 +205,65 @@ describe('findFunctionBlockVariables', () => { expect(names).not.toContain('TMP') expect(names).not.toContain('EXT_REF') }) + + // A derived FB's instance carries every member of its bases, and + // debug-table-gen walks the same chain — so a member missing here is one + // debug-map.json offers and the watch panel cannot reach. + describe('EXTENDS', () => { + const fb = (name: string, base: string | undefined, names: string[]): PLCPou => ({ + name, + pouType: 'function-block', + interface: { + ...(base ? { extends: base } : {}), + variables: names.map((n) => ({ + name: n, + class: 'local' as const, + type: { definition: 'base-type' as const, value: 'INT' }, + location: '', + documentation: '', + })), + }, + body: { language: 'st', value: '' }, + }) + + it('includes inherited members, base first', () => { + const pous = [fb('BASE_FB', undefined, ['TICK', 'COUNT']), fb('FAST_FB', 'BASE_FB', ['SELF_KIND'])] + const vars = findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS) + expect(vars!.map((v) => v.name)).toEqual(['TICK', 'COUNT', 'SELF_KIND']) + }) + + it('walks a chain more than one level deep', () => { + const pous = [fb('A_FB', undefined, ['A1']), fb('B_FB', 'A_FB', ['B1']), fb('C_FB', 'B_FB', ['C1'])] + expect(findFunctionBlockVariables('C_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['A1', 'B1', 'C1']) + }) + + it('lets a derived declaration hide the base one of the same name', () => { + const base = fb('BASE_FB', undefined, ['KIND']) + const derived = fb('FAST_FB', 'BASE_FB', ['KIND']) + derived.interface!.variables[0].type = { definition: 'base-type', value: 'REAL' } + const vars = findFunctionBlockVariables('FAST_FB', [base, derived], SYSTEM_LIBS) + expect(vars!.map((v) => v.name)).toEqual(['KIND']) + expect(vars![0].type.value).toBe('REAL') + }) + + it('resolves the base name case-insensitively', () => { + const pous = [fb('BASE_FB', undefined, ['TICK']), fb('FAST_FB', 'base_fb', ['SELF_KIND'])] + expect(findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual([ + 'TICK', + 'SELF_KIND', + ]) + }) + + it('stops at a base that is not a project POU', () => { + const pous = [fb('FAST_FB', 'NOT_A_POU', ['SELF_KIND'])] + expect(findFunctionBlockVariables('FAST_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['SELF_KIND']) + }) + + it('ends a cyclic chain instead of hanging', () => { + const pous = [fb('A_FB', 'B_FB', ['A1']), fb('B_FB', 'A_FB', ['B1'])] + expect(findFunctionBlockVariables('A_FB', pous, SYSTEM_LIBS)!.map((v) => v.name)).toEqual(['B1', 'A1']) + }) + }) }) // --------------------------------------------------------------------------- @@ -250,6 +310,22 @@ describe('findFunctionBlockExternalVariables', () => { // SR is a system-library FB; its externals are not project-visible. expect(findFunctionBlockExternalVariables('SR', [])).toEqual([]) }) + + it('picks up a base FB’s externals, listing one declared at both levels once', () => { + const base: PLCPou = { + name: 'BaseFB', + pouType: 'function-block', + interface: { variables: [makeVar('G1', 'external'), makeVar('G2', 'external')] }, + body: { language: 'st', value: '' }, + } + const derived: PLCPou = { + name: 'MyFB', + pouType: 'function-block', + interface: { extends: 'BaseFB', variables: [makeVar('G2', 'external'), makeVar('S', 'local')] }, + body: { language: 'st', value: '' }, + } + expect(findFunctionBlockExternalVariables('MyFB', [base, derived]).map((v) => v.name)).toEqual(['G2', 'G1']) + }) }) // --------------------------------------------------------------------------- @@ -302,6 +378,64 @@ describe('findStructureVariables', () => { }) }) +// --------------------------------------------------------------------------- +// findArrayDataType +// --------------------------------------------------------------------------- + +describe('findArrayDataType', () => { + const arrayOf = (name: string, baseType: { definition: string; value: string }) => + ({ + name, + derivation: 'array', + baseType, + dimensions: [{ dimension: '0..7' }], + }) as unknown as PLCDataType + + const dataTypes: PLCDataType[] = [ + arrayOf('A_PROFILE', { definition: 'base-type', value: 'REAL' }), + arrayOf('A_MOTORS', { definition: 'user-data-type', value: 'S_MOTOR' }), + arrayOf('A_TIMERS', { definition: 'derived', value: 'TON' }), + arrayOf('A_GRID', { definition: 'array', value: 'ARRAY [0..1] OF INT' }), + { name: 'S_MOTOR', derivation: 'structure', variable: [] }, + { name: 'E_MODE', derivation: 'enumerated', values: [{ description: 'OFF' }] }, + ] + + it('returns the element type and bounds, case-insensitively', () => { + expect(findArrayDataType('a_profile', dataTypes)).toEqual({ + baseType: { definition: 'base-type', value: 'REAL' }, + dimensions: [{ dimension: '0..7' }], + }) + }) + + it('carries a structure element through unchanged', () => { + expect(findArrayDataType('A_MOTORS', dataTypes)?.baseType).toEqual({ + definition: 'user-data-type', + value: 'S_MOTOR', + }) + }) + + // The array walk disambiguates an FB from a structure by name, so a `derived` + // element reaches it as a user-data-type rather than a shape it cannot type. + it('reports a function-block element as a user data type', () => { + expect(findArrayDataType('A_TIMERS', dataTypes)?.baseType).toEqual({ + definition: 'user-data-type', + value: 'TON', + }) + }) + + // A nested array needs a second subscript this shape cannot express; a leaf + // is better than elements reported with the wrong bounds. + it('returns null for an array of arrays', () => { + expect(findArrayDataType('A_GRID', dataTypes)).toBeNull() + }) + + it('returns null for a structure, an enumeration and an unknown name', () => { + expect(findArrayDataType('S_MOTOR', dataTypes)).toBeNull() + expect(findArrayDataType('E_MODE', dataTypes)).toBeNull() + expect(findArrayDataType('NoSuchType', dataTypes)).toBeNull() + }) +}) + // --------------------------------------------------------------------------- // isStructureType // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/__tests__/variable-sizes.test.ts b/src/frontend/utils/__tests__/variable-sizes.test.ts index 2424e46dd..6c36fc14e 100644 --- a/src/frontend/utils/__tests__/variable-sizes.test.ts +++ b/src/frontend/utils/__tests__/variable-sizes.test.ts @@ -567,11 +567,28 @@ describe('encodeForceValue', () => { expect(() => encodeForceValue('10', 'TIME')).toThrow(/Invalid TIME value/) }) - it('encodes STRING as a length byte followed by ASCII', () => { - expect(Array.from(encodeForceValue('hi', 'STRING'))).toEqual([2, 0x68, 0x69]) + it('encodes STRING as a length byte followed by ASCII, in the full wire window', () => { + // The window is fixed at `1 + DEBUG_STRING_CAP`, not `1 + text.length`. + // `handle_set` in the runtime compares the received length against + // `type_ops[tag].size` and answers STATUS_DATA_TOO_LARGE below it, so a + // variable-length buffer made every string force a silent no-op: the flag + // showed as set from the session's own bookkeeping, and the value never + // moved. + const WIRE = 1 + 126 + + const hi = encodeForceValue('hi', 'STRING') + expect(hi.length).toBe(WIRE) + expect(Array.from(hi.subarray(0, 3))).toEqual([2, 0x68, 0x69]) + // The tail is zero — the reader decodes `min(length, CAP)` and ignores it. + expect(Array.from(hi.subarray(3)).every((b) => b === 0)).toBe(true) + // IEC literal quotes are unwrapped — and are how spaces survive trimming. - expect(Array.from(encodeForceValue("' hi '", 'STRING'))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) - expect(Array.from(encodeForceValue("''", 'STRING'))).toEqual([0]) + const spaced = encodeForceValue("' hi '", 'STRING') + expect(Array.from(spaced.subarray(0, 5))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) + + const empty = encodeForceValue("''", 'STRING') + expect(empty.length).toBe(WIRE) + expect(Array.from(empty).every((b) => b === 0)).toBe(true) }) it('rejects STRING values that are non-ASCII or over the protocol cap', () => { diff --git a/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts b/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts index 04868e7ed..63a9b69a0 100644 --- a/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts +++ b/src/frontend/utils/cpp/__tests__/generateSTCode.test.ts @@ -182,6 +182,33 @@ describe('generateSTCode (cpp)', () => { expect(result).toContain('vars.IOVAL = &IOVAL;') }) + it('passes a variable-length array as the view itself, not as an element pointer', () => { + // strucpp types an `ARRAY [*] OF INT` pin `ArrayView1D`, which + // carries the runtime bounds. Offsetting to the first element would drop the + // length and index `data_[0 - lower]`, out of range for a non-zero lower + // bound. + const values: PLCVariable = { + name: 'values', + class: 'inOut', + type: { + definition: 'array', + value: 'ARRAY [*] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '*' }], + }, + }, + location: '', + documentation: '', + debug: false, + } + + const result = generateSTCode({ pouName: 'VlaBlock', allVariables: [values] }) + + expect(result).toContain('vars.VALUES = &VALUES;') + expect(result).not.toContain('&VALUES[') + }) + describe('VAR_EXTERNAL', () => { const ext = (name: string): PLCVariable => ({ name, diff --git a/src/frontend/utils/cpp/generateSTCode.ts b/src/frontend/utils/cpp/generateSTCode.ts index d237df04a..ec8c0ccf5 100644 --- a/src/frontend/utils/cpp/generateSTCode.ts +++ b/src/frontend/utils/cpp/generateSTCode.ts @@ -1,5 +1,10 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' -import { getArrayStartIndex, isArrayVariable, multiDimensionalContainerType } from '../PLC/array-codegen-helpers' +import { + getArrayStartIndex, + isArrayVariable, + isVariableLengthArray, + multiDimensionalContainerType, +} from '../PLC/array-codegen-helpers' import { cBlockExternalVariables, cBlockInterfaceVariables } from './block-interface' type STCodeGenerationParams = { @@ -28,10 +33,17 @@ type STCodeGenerationParams = { * index in one `operator()` call — so the container itself is passed and the * block indexes it as `grid(i, j)`, the same accessor the compiler's own * generated code uses. + * + * - Variable-length arrays: `vars.NAME = &NAME`, for the same reason as a + * multi-dimensional one. The pin is an `ArrayViewD`, whose bounds are not + * known until the call, so passing the first element would drop the only + * record of how many elements there are — and there is no lower bound yet to + * offset by. Passing the view keeps `lower_bound()` / `upper_bound()` / `at()` + * reachable from the block. */ const generateVariableAssignment = (variable: PLCVariable): string => { const name = variable.name.toUpperCase() - if (multiDimensionalContainerType(variable)) { + if (multiDimensionalContainerType(variable) || isVariableLengthArray(variable)) { return `vars.${name} = &${name};\n` } if (isArrayVariable(variable)) { diff --git a/src/frontend/utils/debug-tree-traversal.ts b/src/frontend/utils/debug-tree-traversal.ts index 6014b07bc..e776a1ff1 100644 --- a/src/frontend/utils/debug-tree-traversal.ts +++ b/src/frontend/utils/debug-tree-traversal.ts @@ -16,6 +16,7 @@ import { findDebugVariableForField, } from './debug-variable-finder' import { + findArrayDataType, findFunctionBlockExternalVariables, findFunctionBlockVariables, findStructureVariables, @@ -269,6 +270,14 @@ function traverseNestedNode( return visitor.visitComplex(name, fullPath, compositeKey, typeName, children) } else if (typeDefinition === 'user-data-type') { + // An array data type (`TYPE A_PROFILE : ARRAY [0..7] OF REAL`) is a user + // data type by name and an array by shape, and STruC++ emits its elements + // as `NAME[i]` like any other array. Without this it collapses to one leaf. + const arrayDataType = findArrayDataType(typeName, dataTypes) + if (arrayDataType) { + return traverseNestedNode(name, fullPath, compositeKey, typeName, 'array', context, visitor, arrayDataType) + } + // Structure type — STruC++ emits struct fields as `PARENT.FIELD` // (same convention as FB fields), no `.value.` shim. const structVariables = findStructureVariables(typeName, dataTypes) diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index 210884ca0..e135bb81b 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -1,7 +1,7 @@ import type { LibraryState } from '../../middleware/shared/ports/library-types' import { baseTypeSchema } from '../../middleware/shared/ports/plc-schemas' import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' -import { DEBUG_STRING_CAP } from './variable-sizes' +import { MAX_STRING_LENGTH, parseStringLength } from './iec-types-registry' const varBlockToClass: Record = { VAR: 'local', @@ -46,17 +46,23 @@ export const DISALLOWED_LOCATION_CLASSES: ReadonlyArray = // `parseArrayType` has declined it, and `parseArrayType` declines blank bounds. // Both guards are below; between them, a comma reaches the store only as part of // a well-formed multi-dimensional ARRAY. +// +// It also accepts `*`, the bound of a variable-length array +// (`values : ARRAY [*] OF INT;`), and `(` / `)` for a declared string length +// (`name : STRING(23);`). The parentheses cannot swallow a `(*` comment: the +// group is lazy and must be followed by `;`, and anything before that +// semicolon is rejected by `baseTypeSchema` and `identifierRegex`. // Primary format: name : type AT location := initialValue ; (* documentation *) const lineRegex = // eslint-disable-next-line no-useless-escape - /^\s*(?\w+)\s*:\s*(?[\w\s\[\],\.]+?)(?:\s+AT\s+(?[\w\d\._%]+))?\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^\s*(?\w+)\s*:\s*(?[\w\s\[\]\(\),\.\*]+?)(?:\s+AT\s+(?[\w\d\._%]+))?\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ // Alternate format: name AT location : type := initialValue ; (* documentation *) // This format is used by some IEC 61131-3 tools and older versions of OpenPLC Editor const alternateLineRegex = // eslint-disable-next-line no-useless-escape - /^\s*(?\w+)\s+AT\s+(?[\w\d\._%]+)\s*:\s*(?[\w\s\[\],\.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + /^\s*(?\w+)\s+AT\s+(?[\w\d\._%]+)\s*:\s*(?[\w\s\[\]\(\),\.\*]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ const guessErrorReason = (line: string): string => { if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' @@ -81,8 +87,12 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type * Also consumed by the data-type text parser (`PLC/data-type-text-parser.ts`). */ export const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { - // Match ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally namespaced) - const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*)\s*$/i) + // ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally + // namespaced) that may carry a declared string length — + // `ARRAY [0..3] OF STRING(23)`. + const arrayMatch = typeStr.match( + /^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*(?:\s*[([]\s*\d+\s*[)\]])?)\s*$/i, + ) if (!arrayMatch) return null const dimensionsStr = arrayMatch[1] @@ -214,33 +224,29 @@ export const parseIecStringToVariables = ( ) } - // A length-qualified string (`STRING[20]`, `WSTRING[8]`) is legal IEC and - // legal CODESYS, and STruC++ does not accept it. Left alone it is not even - // recognised as a string: it becomes a user data type literally named - // "STRING[20]", which is persisted, shown in the type cell, and emitted - // verbatim into the generated ST — where the compiler fails with - // `Expected Semicolon, found [` pointing at a line the user never wrote. + // A length-qualified string — `STRING(23)`, `WSTRING(8)`. STruC++ emits + // `IECStringVar<23>` at 54 bytes where a plain STRING is 518. Square + // brackets are accepted and normalised to the parenthesised form. // - // Refusing here says what is true today. The transport carries a fixed - // DEBUG_STRING_CAP-character budget, so a declared length would not be honoured even if - // it parsed; when the compiler grows the declaration, this guard is the one - // place that has to change. - // Both shapes it can take: on its own (`msg : STRING[20]`) and as an ARRAY's - // element type (`tags : ARRAY [0..3] OF STRING[20]`). The array form needs - // its own alternative because `parseArrayType` only accepts a bare - // identifier after `OF`, so a length-qualified element matches nothing and - // used to fall through every branch to the compiler — which then reported - // `Expected Semicolon, found [` at a column the user never wrote, plus two - // cascading errors on the FOLLOWING line, so even the line number misled. - const lengthQualifiedString = - /^(W?STRING)\s*\[\s*[^\]]*\]$/i.exec(parsedType) ?? - /^ARRAY\s*\[[^\]]*\]\s+OF\s+(W?STRING)\s*\[\s*[^\]]*\]\s*$/i.exec(parsedType) - if (lengthQualifiedString) { - const keyword = lengthQualifiedString[1].toUpperCase() - throw new Error( - `Syntax error on line ${lineNumber}: "${line}". A declared length is not supported on ${keyword} — ` + - `use plain ${keyword}, which carries up to ${DEBUG_STRING_CAP} characters.`, - ) + // Only a malformed or out-of-range length is refused, and refused here + // rather than left to fall through: an unrecognised type is stored as a + // user data type named "STRING(0)" and emitted verbatim into generated ST. + // + // The array element form is handled by `parseArrayType` above. + const stringWithLength = /^(W?STRING)\s*[([]\s*([^)\]]*?)\s*[)\]]$/i.exec(parsedType) + if (stringWithLength) { + // Matching the shape commits to a length, so `STRING[]`, `STRING(abc)`, + // `STRING(0)` and `STRING(999)` are all reported here. `parseStringLength` + // returns `valid: true` with no length for an unqualified name, so the + // undefined case must be caught explicitly. + const { length, valid } = parseStringLength(parsedType) + if (length === undefined || !valid) { + throw new Error( + `Syntax error on line ${lineNumber}: "${line}". ` + + `${stringWithLength[1].toUpperCase()} takes a length from 1 to ${MAX_STRING_LENGTH}, ` + + `got "${stringWithLength[2]}".`, + ) + } } const baseCheck = baseTypeSchema.safeParse(parsedType.toUpperCase()) diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index 30574982d..1d6569e24 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -92,6 +92,44 @@ const INDEX: ReadonlyMap = (() => { return m })() +/** + * Largest declared length a STRING / WSTRING may carry — the capacity of the + * unqualified type, so `STRING` and `STRING(254)` are the same declaration. + */ +export const MAX_STRING_LENGTH = 254 + +/** Only these two carry a declared length. */ +const LENGTH_QUALIFIED = new Set(['STRING', 'WSTRING']) + +/** Whether a declared length may be written after this type name. */ +export function isLengthQualifiedType(name: string): boolean { + return LENGTH_QUALIFIED.has(name.trim().toUpperCase()) +} + +/** + * Split a declared type into its base name and its optional length. + * `STRING(23)` is the form STruC++ parses; `STRING[23]` is accepted and + * normalised to it. + * + * `length` is undefined when none was written; `valid` is false for a length + * this implementation cannot carry, so callers can tell `STRING` from + * `STRING(0)`. + */ +export function parseStringLength(name: string): { + base: string + length?: number + valid: boolean +} { + const trimmed = name.trim() + const match = /^([A-Za-z_]\w*)\s*[([]\s*(\d+)\s*[)\]]$/.exec(trimmed) + if (!match) return { base: trimmed.toUpperCase(), valid: true } + + const base = match[1].toUpperCase() + const length = Number(match[2]) + const valid = LENGTH_QUALIFIED.has(base) && length >= 1 && length <= MAX_STRING_LENGTH + return { base, length, valid } +} + /** * Resolve a name (canonical or alias, any case, with surrounding * whitespace) to its metadata. Returns `undefined` for non-elementary @@ -100,9 +138,14 @@ const INDEX: ReadonlyMap = (() => { * `'STRING'`, `'string'`, and the occasional whitespace-padded value; * normalising here means downstream callers don't each re-implement * it. + * + * A declared length is stripped first, so `STRING(23)` resolves to the STRING + * metadata. Callers wanting the number itself use {@link parseStringLength}. */ export function lookupBaseType(name: string): IECTypeMetadata | undefined { - return INDEX.get(name.trim().toUpperCase()) + const { base, length, valid } = parseStringLength(name) + if (length !== undefined && !valid) return undefined + return INDEX.get(base) } /** @@ -111,7 +154,7 @@ export function lookupBaseType(name: string): IECTypeMetadata | undefined { * pattern. */ export function isBaseTypeName(name: string): boolean { - return INDEX.has(name.trim().toUpperCase()) + return lookupBaseType(name) !== undefined } /** diff --git a/src/frontend/utils/pou-helpers.ts b/src/frontend/utils/pou-helpers.ts index 9e2aebe29..bed24b727 100644 --- a/src/frontend/utils/pou-helpers.ts +++ b/src/frontend/utils/pou-helpers.ts @@ -88,12 +88,44 @@ export const isBaseType = (typeName: string): boolean => { const LIBRARY_FB_INTERFACE_CLASSES: ReadonlySet = new Set(['input', 'output', 'inOut']) const USER_FB_PERSISTENT_CLASSES: ReadonlySet = new Set(['input', 'output', 'inOut', 'local']) +/** + * Find a user-defined function block POU by type name. + */ +const findProjectFunctionBlock = (typeName: string, projectPous: PLCPou[]): PLCPou | undefined => { + const typeNameUpper = typeName.toUpperCase() + const pou = projectPous.find( + (p) => normalizeTypeString(p.pouType) === 'functionblock' && p.name.toUpperCase() === typeNameUpper, + ) + return pou?.pouType === 'function-block' ? pou : undefined +} + +/** + * The `EXTENDS` chain above a user-defined function block, derived first. + * Stops at a base that is not a project POU; `visited` bounds a cycle. + */ +const functionBlockChain = (typeName: string, projectPous: PLCPou[]): PLCPou[] => { + const chain: PLCPou[] = [] + const visited = new Set() + let cursor = findProjectFunctionBlock(typeName, projectPous) + while (cursor && !visited.has(cursor.name.toUpperCase())) { + visited.add(cursor.name.toUpperCase()) + chain.push(cursor) + const base = cursor.interface?.extends + if (!base) break + cursor = findProjectFunctionBlock(base, projectPous) + } + return chain +} + /** * Find a function block definition by name. * Searches BOTH the built-in library AND project POUs. * Returns the variables array from the FB definition, filtered to * match the debugger's variable-enumeration contract (see comment on * LIBRARY_FB_INTERFACE_CLASSES). Returns null if not found. + * + * For a user-defined FB the result spans the whole `EXTENDS` chain, base + * members first, with a derived declaration hiding a base one of the same name. */ export const findFunctionBlockVariables = ( typeName: string, @@ -123,16 +155,22 @@ export const findFunctionBlockVariables = ( // Check project POUs (user-defined FBs) — interface + locals, // dropping temp / external. - const customFB = projectPous.find( - (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + const chain = functionBlockChain(typeName, projectPous) + if (chain.length === 0) return null + + // Claim derived-first so a redeclaration wins, emit base-first. + const claimed = new Set() + const perLevel = chain.map((pou) => + ((pou.interface?.variables ?? []) as PouVariable[]).filter((v) => { + if (v.class !== undefined && !USER_FB_PERSISTENT_CLASSES.has(v.class)) return false + const key = v.name.toUpperCase() + if (claimed.has(key)) return false + claimed.add(key) + return true + }), ) - if (customFB && customFB.pouType === 'function-block') { - return ((customFB.interface?.variables ?? []) as PouVariable[]).filter((v) => - v.class === undefined ? true : USER_FB_PERSISTENT_CLASSES.has(v.class), - ) - } - return null + return perLevel.reverse().flat() } /** @@ -151,16 +189,21 @@ export const findFunctionBlockVariables = ( * * Returns full PLCVariable objects (not the narrowed PouVariable view) so the * debug traversal can hand them straight to traverseVariable. + * + * The `EXTENDS` chain is walked too, deduped by name: every level resolves to + * the same canonical `Config0:` node. */ export const findFunctionBlockExternalVariables = (typeName: string, projectPous: PLCPou[]): PLCVariable[] => { - const typeNameUpper = typeName.toUpperCase() - const customFB = projectPous.find( - (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + const seen = new Set() + return functionBlockChain(typeName, projectPous).flatMap((pou) => + (pou.interface?.variables ?? []).filter((v) => { + if (v.class !== 'external') return false + const key = v.name.toUpperCase() + if (seen.has(key)) return false + seen.add(key) + return true + }), ) - if (customFB && customFB.pouType === 'function-block') { - return (customFB.interface?.variables ?? []).filter((v) => v.class === 'external') - } - return [] } /** @@ -193,6 +236,33 @@ export const isStructureType = (typeName: string, dataTypes: PLCDataType[]): boo return findStructureVariables(typeName, dataTypes) !== null } +/** + * Find an array data type by name — `TYPE A_PROFILE : ARRAY [0..7] OF REAL`. + * Returns the element type and bounds in the same shape an inline + * `ARRAY [..] OF ..` carries in `type.data`. Null for anything else. + */ +export const findArrayDataType = ( + typeName: string, + dataTypes: PLCDataType[], +): { + baseType: { definition: 'base-type' | 'user-data-type'; value: string } + dimensions: Array<{ dimension: string }> +} | null => { + const dataType = dataTypes.find((dt) => dt.name.toLowerCase() === typeName.toLowerCase()) + if (dataType?.derivation !== 'array') return null + + // `baseType` is a full PLCVariableType, wider than the array walk accepts. + // `derived` names an FB, which the walk resolves by name anyway; a nested + // `array` element needs a second subscript this shape cannot carry. + const { definition, value } = dataType.baseType + if (definition !== 'base-type' && definition !== 'user-data-type' && definition !== 'derived') return null + + return { + baseType: { definition: definition === 'derived' ? 'user-data-type' : definition, value }, + dimensions: dataType.dimensions, + } +} + /** * Check if a type name is an enumeration. */ diff --git a/src/frontend/utils/variable-sizes.ts b/src/frontend/utils/variable-sizes.ts index 466f83088..037d29d0a 100644 --- a/src/frontend/utils/variable-sizes.ts +++ b/src/frontend/utils/variable-sizes.ts @@ -431,7 +431,12 @@ function encodeByWireFormat(originalInput: string, numericInput: string, meta: I if (text.length > DEBUG_STRING_CAP) { throw new Error(`STRING value too long: ${text.length} characters (max ${DEBUG_STRING_CAP})`) } - const buf = new Uint8Array(1 + text.length) + // The full wire window, not `1 + text.length`: `handle_set` compares the + // received length against `type_ops[tag].size` — the fixed 127-byte + // window for a STRING — and answers STATUS_DATA_TOO_LARGE below it, so a + // short buffer makes the force a silent no-op. The zero tail is ignored; + // the reader decodes `min(length, CAP)` units from the prefix. + const buf = new Uint8Array(1 + DEBUG_STRING_CAP) buf[0] = text.length for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i) diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index 21b6ca888..1b18d6475 100644 --- a/src/middleware/adapters/editor/compiler-adapter.ts +++ b/src/middleware/adapters/editor/compiler-adapter.ts @@ -55,6 +55,7 @@ export interface IpcProjectData { name: string variables: unknown[] returnType?: string + extends?: string body: { language: string; value: unknown } documentation: string } @@ -74,6 +75,9 @@ function portPouToIpcPou(pou: PLCPou) { name: pou.name, variables: (pou.interface?.variables ?? []) as unknown[], ...(pou.interface?.returnType ? { returnType: pou.interface.returnType } : {}), + // Restated field by field, so anything unnamed is dropped — `extends` + // included, and the derived block then reaches the compiler with no base. + ...(pou.interface?.extends ? { extends: pou.interface.extends } : {}), body: pou.body as { language: string; value: unknown }, documentation: pou.documentation ?? '', }, diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 819c56fc2..e7787162b 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -41,6 +41,8 @@ interface IpcPou { language?: string variables: unknown[] returnType?: string + /** Base function block, from `FUNCTION_BLOCK X EXTENDS Y`. */ + extends?: string body: { language: string; value: unknown } documentation: string variablesText?: string @@ -82,6 +84,9 @@ interface IpcPouResponse { /** * Maps editor discriminated-union POU to port flat POU format. + * + * Both mappings name every field they carry, so anything unlisted is dropped — + * `extends` included, or the base is lost on load. */ function mapIpcPouToPortPou(ipcPou: IpcPou): PLCPou { return { @@ -89,6 +94,7 @@ function mapIpcPouToPortPou(ipcPou: IpcPou): PLCPou { pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType, + ...(ipcPou.data.extends ? { extends: ipcPou.data.extends } : {}), variables: ipcPou.data.variables as PLCVariable[], }, body: ipcPou.data.body as PLCPou['body'], @@ -107,6 +113,7 @@ function mapPortPouToIpcPou(portPou: PLCPou): IpcPou { language: portPou.body.language, variables: (portPou.interface?.variables ?? []) as unknown[], ...(portPou.interface?.returnType ? { returnType: portPou.interface.returnType } : {}), + ...(portPou.interface?.extends ? { extends: portPou.interface.extends } : {}), body: portPou.body as { language: string; value: unknown }, documentation: portPou.documentation ?? '', }, diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index 60af8db42..eaa0a192b 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -35,6 +35,7 @@ * server-side, but the pipeline never knows. */ +import type { BundleFile } from '../utils/library/bundle-file' import type { PLCProjectData, StructuredCompileError } from './types' /** @@ -118,8 +119,12 @@ export interface CompileArduinoArgs { * `src/c_blocks.h`, `examples/Baremetal/c_blocks_code.cpp`, * `src/defines.h`, and the bundled firmware skeleton + strucpp * runtime headers. On editor: written to disk before - * arduino-cli runs. On web: POSTed in the request body. */ - files: Record + * arduino-cli runs. On web: POSTed in the request body. + * + * Almost every entry is generated text. An entry carrying bytes — a + * library's precompiled `.a` — arrives as `{ base64 }` and must be written + * decoded; see `BundleFile`. */ + files: Record /** Argv suffix for arduino-cli compile (after the `compile` * subcommand). Comes from the shared `buildArduinoCliCompileArgs` * helper. */ @@ -148,8 +153,9 @@ export interface CompileArduinoResult { export interface UploadRuntimeV4Args { /** File map the runtime extracts on the device. Already * composed by `composeRuntimeV4Bundle`; the pipeline passes it - * straight through. */ - bundle: Record + * straight through. A `{ base64 }` entry carries bytes — see + * `BundleFile`. */ + bundle: Record /** Discriminated device context; see `PlatformDeviceContext`. */ context: PlatformDeviceContext } @@ -397,8 +403,9 @@ export interface CompilerPlatformPort { } export interface MaterializeRuntimeV4BundleArgs { - /** Path → file content, as composed by `composeRuntimeV4Bundle`. */ - bundle: Record + /** Path → file content, as composed by `composeRuntimeV4Bundle`. A + * `{ base64 }` entry carries bytes — see `BundleFile`. */ + bundle: Record } export interface MaterializeRuntimeV4BundleResult { diff --git a/src/middleware/shared/ports/library-build-port.ts b/src/middleware/shared/ports/library-build-port.ts index 14af822ce..e9f21ba63 100644 --- a/src/middleware/shared/ports/library-build-port.ts +++ b/src/middleware/shared/ports/library-build-port.ts @@ -159,6 +159,24 @@ export interface LibraryBuildPort { */ 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 diff --git a/src/middleware/shared/ports/library-port.ts b/src/middleware/shared/ports/library-port.ts index ecbf3ce0c..e652b0c79 100644 --- a/src/middleware/shared/ports/library-port.ts +++ b/src/middleware/shared/ports/library-port.ts @@ -115,6 +115,10 @@ export interface StlibArchiveDTO { 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' }> } 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/types.ts b/src/middleware/shared/ports/types.ts index 7a6a00026..bf71a906d 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -151,6 +151,12 @@ export interface PLCPou { pouType: PouType interface?: { returnType?: string + /** + * Base function block, from `FUNCTION_BLOCK X EXTENDS Y`; undefined when + * the POU derives from nothing. On the interface because the clause changes + * the block's pins and methods, as `returnType` does for a FUNCTION. + */ + extends?: string variables: PLCVariable[] } body: PLCBody 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 4b6becf3c..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 @@ -64,7 +66,7 @@ export interface ComposeRuntimeV4BundleInput { * 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 }> }> + 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 @@ -107,15 +109,16 @@ 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.content + files[`libraries/${library.name}/${file.path}`] = + file.encoding === 'base64' ? { base64: file.content } : file.content } } 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}/`) +} From 5ea9bfab593e4fc8c13301bd35ee9a7ff9533f12 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Sun, 6 Sep 2026 11:17:44 +1200 Subject: [PATCH 03/13] Derive the IEC type aliases from the registry, and let a VAR block carry comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BASE_TYPE_TO_IEC was a hand-written map from an elementary type to the IEC_* alias STruC++ declares for it. A type added to the registry was not added here, which is how TIME came to emit strucpp::TIME — a name nothing declares — and failed the build on generated code the user never wrote. It is now derived from IEC_BASE_TYPES, so the registry is the only place a type is stated. generateIecStringToVariables stopped at a comment on a line of its own, which is legal ST and is how a long VAR block is given section headings. The whole block then parsed as nothing and exported blank. Full-line comments are now skipped, single and multi-line alike, while a trailing comment still belongs to the declaration in front of it and is kept as its documentation. Adds an npm typecheck script, so tsc --noEmit is one command rather than a remembered incantation. --- package.json | 1 + .../utils/PLC/array-codegen-helpers.ts | 53 +++++++------------ .../generate-iec-string-to-variables.test.ts | 44 +++++++++++++++ .../utils/generate-iec-string-to-variables.ts | 17 ++++++ 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 56a3f9ca4..f4707a158 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", "setup:binaries": "ts-node scripts/download-binaries.ts", diff --git a/src/frontend/utils/PLC/array-codegen-helpers.ts b/src/frontend/utils/PLC/array-codegen-helpers.ts index 017f70dae..7254630b3 100644 --- a/src/frontend/utils/PLC/array-codegen-helpers.ts +++ b/src/frontend/utils/PLC/array-codegen-helpers.ts @@ -1,39 +1,26 @@ import type { PLCVariable } from '../../../middleware/shared/ports/types' -import { parseStringLength } from '../iec-types-registry' +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. 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 4128cd8bf..b33e06d08 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 @@ -292,6 +292,50 @@ describe('parseIecStringToVariables', () => { expect(result[0].type).toEqual({ definition: 'user-data-type', value: 'Motor' }) }) + // A standalone comment inside a VAR block is legal ST and is how a long + // declaration list is given section headings. It used to be a syntax error, + // so a readable program failed to load with "No variable defined". + describe('a comment on a line of its own', () => { + it('is skipped inside a VAR block', () => { + const result = parseIecStringToVariables( + 'VAR\n' + + ' (* --- the node --- *)\n' + + ' node : DINT;\n' + + '\n' + + ' (* --- the plant --- *)\n' + + ' level : REAL;\n' + + 'END_VAR', + ) + expect(result.map((v) => v.name)).toEqual(['node', 'level']) + }) + + it('leaves a trailing comment with its declaration', () => { + const result = parseIecStringToVariables('VAR\n level : REAL; (* tank A *)\nEND_VAR') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('level') + expect(result[0].documentation).toBe('tank A') + }) + + it('skips a comment spread over several lines', () => { + const result = parseIecStringToVariables( + 'VAR\n' + + ' (* ==========================\n' + + ' WHAT WE WATCH\n' + + ' ========================== *)\n' + + ' level : REAL;\n' + + 'END_VAR', + ) + expect(result.map((v) => v.name)).toEqual(['level']) + }) + + it('reads the declaration that follows a multi-line comment', () => { + const result = parseIecStringToVariables( + 'VAR\n (* one\n two *)\n a : INT;\n (* three *)\n b : BOOL;\nEND_VAR', + ) + expect(result.map((v) => v.name)).toEqual(['a', 'b']) + }) + }) + describe('a declared string length', () => { // STruC++ emits `IECStringVar<23>` — 54 bytes against 518 for the // unqualified type. diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index e135bb81b..5ca10c7d6 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -153,11 +153,28 @@ export const parseIecStringToVariables = ( const lines = iecString.split(/\r?\n/) let currentClass: PLCVariable['class'] | null = null + let inComment = false + lines.forEach((rawLine, idx) => { const lineNumber = idx + 1 const line = rawLine.trim() if (line === '') return + // A comment on a line of its own, which is legal ST and is how a long VAR + // block is given section headings — on one line or spread over several. + // + // Only a comment that STARTS a line is skipped: a trailing one still + // belongs to the declaration in front of it, and is parsed as its + // documentation. + if (inComment) { + if (line.includes('*)')) inComment = false + return + } + if (line.startsWith('(*')) { + if (!line.includes('*)')) inComment = true + return + } + const blockStart = line.match(/^(VAR_INPUT|VAR_OUTPUT|VAR_IN_OUT|VAR_EXTERNAL|VAR_TEMP|VAR_GLOBAL|VAR)\b/i) if (blockStart) { currentClass = varBlockToClass[blockStart[1].toUpperCase()] From 9ac59bb51f84ea742c2cc2313f0261fd74a523aa Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Sun, 6 Sep 2026 19:10:00 +1200 Subject: [PATCH 04/13] Hold a sized string to one rule, and stop two archives merging into one folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two archives shipping the same library folder merged their files, so a build could pair a header from one version with a source from another. Each archive's folders are now staged and replace the earlier ones whole, which is what the function already claimed to do. A malformed resource record is skipped rather than aborting the compile on its missing path. Sized strings are checked the same way wherever they are written: matched delimiters only, and an array element held to the scalar rule, so `STRING(23]` and `ARRAY[0..1] OF STRING(0)` are typos again instead of user data types named after the mistake. A generic in element position spells `IEC_ANY`, not `ANY`. `compile_commands.json`'s `command` is tokenized rather than split on whitespace, so a quoted `-I` path with a space survives, and the verification payload gets the same shape check as the build payload — `{}` used to pass. Four faults that failed quietly: the string length carried into the next variable and across a recycled table row; Enter applied a type beside Radix instead of through it, leaving the menu open; double-clicking Build Settings opened a rename nothing could accept, which the actions popover was already gated against; and a rejected resource call told the user nothing at all. Also: a subframe failure no longer reloads the whole window, the fallback POU parser shares the multi-block documentation extractor rather than keeping its own single-shot copy, `extends` must be a name before it reaches generated ST, and three `as unknown as string` on a value already typed `string` are gone. Covers the library function-block mapping in the compiler adapter, which had no test, and takes the formatting the merge left behind. --- .../editor/compiler/compiler-module.spec.ts | 8 +- .../editor/compiler/compiler-module.ts | 68 +++++++++------- .../project-service/utils/read-project.ts | 9 ++- src/backend/shared/compile/pipeline.ts | 64 +++++++++------ .../transpilers/st-transpiler/from-schema.ts | 4 +- .../cpp/__tests__/generateCBlocksCode.test.ts | 1 - .../shared/utils/parse-project-files.ts | 6 +- src/cli/commands/library.ts | 21 ++--- .../_atoms/string-length-menu-item/index.tsx | 7 +- .../_atoms/type-dropdown-selector/index.tsx | 7 +- .../editor/build-settings/resources-tab.tsx | 39 +++++++--- .../structure/table/selectable-cell.tsx | 2 +- .../selectable-cell.tsx | 2 +- .../_molecules/project-tree/index.tsx | 21 +++-- .../variables-table/selectable-cell.tsx | 2 +- .../create-graphical-variable-modal.tsx | 4 +- .../PLC/__tests__/sized-string-xml.test.ts | 18 ++--- .../utils/PLC/array-codegen-helpers.ts | 9 ++- src/frontend/utils/PLC/pou-text-parser.ts | 2 +- .../generate-iec-string-to-variables.test.ts | 5 +- .../__tests__/iec-types-registry.test.ts | 1 - .../utils/generate-iec-string-to-variables.ts | 77 ++++++++++++------- src/frontend/utils/iec-types-registry.ts | 6 +- src/main/main.ts | 5 +- .../editor/__tests__/compiler-adapter.test.ts | 20 +++++ 25 files changed, 263 insertions(+), 145 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 245288f2b..3cd5c85a9 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -522,7 +522,10 @@ describe('CompilerModule', () => { * database it would have written, into the `--build-path` it was given. */ const writeCompilationDatabase = (cmd: string, includeDirs: readonly string[]) => { - const buildPath = /--build-path\s+(\S+)/.exec(cmd)?.[1] + // `renderArgvAsCmd` quotes any argument holding a space, which + // `os.tmpdir()` does on a machine whose user name has one. + const match = /--build-path\s+(?:"([^"]+)"|(\S+))/.exec(cmd) + const buildPath = match?.[1] ?? match?.[2] if (!buildPath) throw new Error('database run was given no --build-path') fs.mkdirSync(buildPath, { recursive: true }) fs.writeFileSync( @@ -1122,6 +1125,9 @@ describe('CompilerModule', () => { [['/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 9742c8717..f7b8c8215 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -129,43 +129,56 @@ function parseCompileLibraryArgs( 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), + }, } - const data = projectData as Record +} + +/** + * 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 = 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.' } - } - if (typeof verifyProjectData !== 'object' || verifyProjectData === null) { - return { ok: false, error: 'Library build failed: request carried no verification project data.' } - } - 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, - // The verification pass is the same project through the same - // preprocessor, so the checks above cover it too. - verifyProjectData: verifyProjectData as PLCProjectData, - cleanBuild: cleanBuild === true, - nativePous: parseNativePouRefs(rawNativePous), - }, + return { error: `Library build failed: ${what} resource has no task or instance list.` } } + return { value: value as PLCProjectData } } /** @@ -564,7 +577,10 @@ class CompilerModule { const seen = new Set() const flags: string[] = [] for (const entry of entries) { - for (const token of entry.arguments ?? entry.command?.split(/\s+/) ?? []) { + // `command` is one shell line, so it quotes any path holding a space. + // Splitting on whitespace would hand the compiler half a path; the + // recipe tokenizer already knows the quoting rules. + for (const token of entry.arguments ?? (entry.command ? tokenizeRecipe(entry.command) : [])) { if (!token.startsWith('-I') || token.length === 2) continue if (seen.has(token)) continue seen.add(token) 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 ce3af5c0c..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,7 +14,7 @@ import { needsMigration, } from '@root/backend/shared/utils/migrate-project-to-name-type-system' import { getExtensionFromLanguage } from '@root/frontend/utils/PLC/pou-file-extensions' -import { findLastEndVarIndex } from '@root/frontend/utils/PLC/pou-text-parser' +import { extractDocumentation, findLastEndVarIndex } from '@root/frontend/utils/PLC/pou-text-parser' import { detectLanguageFromExtension, parseGraphicalPouFromString, @@ -173,9 +173,10 @@ function detectPouTypeFromPath(filePath: string): string { * @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, diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index e4f746814..df3d7338f 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -366,39 +366,53 @@ function collectLibraryResources( const enabled = new Set((projectData.libraries ?? []).map((ref) => ref.name)) const byLibrary = new Map>() - const addResource = (resource: { path: string; content: string }): void => { - // These paths come off an installed archive and become files under the - // build directory, so they are checked before they are used as one. - if (!isSafeRelativePath(resource.path)) return - // The first segment names the library folder the file belongs to. - const separator = resource.path.indexOf('/') - if (separator <= 0) return - const name = resource.path.slice(0, separator) - let files = byLibrary.get(name) - if (!files) { - files = new Map() - byLibrary.set(name, files) + + /** + * 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) } - files.set(resource.path.slice(separator + 1), resource.content) + for (const [name, files] of staged) byLibrary.set(name, files) } - for (const archive of (enabled.size === 0 ? [] : libraryArchives) as Array<{ - manifest?: { name?: string } - resources?: Array<{ path: string; content: string }> - }>) { - const archiveName = archive?.manifest?.name + 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 - for (const resource of archive.resources ?? []) { - addResource(resource) - } + 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?: Array<{ path: string; content: string }> }).ownLibraryResources - for (const resource of own ?? []) { - addResource(resource) - } + const own = (projectData as { ownLibraryResources?: unknown }).ownLibraryResources + collect(Array.isArray(own) ? own : []) return [...byLibrary].map(([name, files]) => ({ name, diff --git a/src/backend/shared/transpilers/st-transpiler/from-schema.ts b/src/backend/shared/transpilers/st-transpiler/from-schema.ts index 455f1d949..ba73ff302 100644 --- a/src/backend/shared/transpilers/st-transpiler/from-schema.ts +++ b/src/backend/shared/transpilers/st-transpiler/from-schema.ts @@ -141,9 +141,7 @@ function projectPou(pou: SchemaPou): TranspilePou { ...(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 } - : {}), + ...(pou.type === 'function-block' && pou.data.extends ? { extends: pou.data.extends } : {}), }, body: projectBody(pou.data.body), } diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index c7c3590b1..cd4f452b7 100644 --- a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts +++ b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts @@ -332,5 +332,4 @@ describe('generateCBlocksCode', () => { expect(result).toContain('using MOTOR = strucpp::MOTOR;') }) - }) diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index ed364d1e3..a83f4f217 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -386,7 +386,11 @@ function parsePouFile( pouType: ipcPou.type as PLCPou['pouType'], interface: { returnType: ipcPou.data.returnType as string | undefined, - ...(ipcPou.data.extends ? { extends: ipcPou.data.extends as string } : {}), + // 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/cli/commands/library.ts b/src/cli/commands/library.ts index c190ac790..39fff3936 100644 --- a/src/cli/commands/library.ts +++ b/src/cli/commands/library.ts @@ -224,10 +224,7 @@ async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefin const result = await new LibraryManagerModule().installFromFile(stlibPath) if (!result.success) { - return reporter.failure( - { code: ErrorCode.InvalidArgument, message: result.error }, - ExitCode.TargetError, - ) + return reporter.failure({ code: ErrorCode.InvalidArgument, message: result.error }, ExitCode.TargetError) } if (result.canceled) { return reporter.failure( @@ -244,14 +241,12 @@ async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefin function runLibraryList(reporter: Reporter): CliResult { const installed = new LibraryManagerModule().listInstalled() - return reporter.success( - { ok: true, libraries: installed }, - () => - installed.length === 0 - ? 'No libraries installed.' - : renderTable( - ['Name', 'Version', 'Origin'], - installed.map((library) => [library.name, library.version, library.origin]), - ), + return reporter.success({ ok: true, libraries: installed }, () => + installed.length === 0 + ? 'No libraries installed.' + : renderTable( + ['Name', 'Version', 'Origin'], + installed.map((library) => [library.name, library.version, library.origin]), + ), ) } diff --git a/src/frontend/components/_atoms/string-length-menu-item/index.tsx b/src/frontend/components/_atoms/string-length-menu-item/index.tsx index 2d6089617..d45f54edf 100644 --- a/src/frontend/components/_atoms/string-length-menu-item/index.tsx +++ b/src/frontend/components/_atoms/string-length-menu-item/index.tsx @@ -60,9 +60,14 @@ export const StringLengthMenuItem = ({ onChange={(e) => 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() - if (e.key === 'Enter' && valid) onApply(declaredType) }} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} diff --git a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx index 571764b6b..5aa5e3295 100644 --- a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx +++ b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx @@ -1,6 +1,6 @@ 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' @@ -29,6 +29,11 @@ export const TypeDropdownSelector = ({ }) 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 ( 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 index 1d5b8d294..af5a15d2e 100644 --- a/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/build-settings/resources-tab.tsx @@ -17,6 +17,9 @@ 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() @@ -32,19 +35,31 @@ const ResourcesTab = () => { // `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 - const result = await projectPort.listLibraryResources() - setPendingRemoval(null) - if (!result.success) { - toast({ title: 'Could not read resources', description: result.error, variant: 'fail' }) - 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' }) } - const next = result.folders ?? [] - setFolders(next) - // Keep the selection only while it still names a folder. - setSelected((current) => (current && next.some((f) => f.name === current) ? current : (next[0]?.name ?? null))) }, [projectPort, toast]) useEffect(() => { @@ -65,6 +80,8 @@ const ResourcesTab = () => { 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) } @@ -80,6 +97,8 @@ const ResourcesTab = () => { return } await refresh() + } catch (error) { + toast({ title: `Could not remove ${name}`, description: errorText(error), variant: 'fail' }) } finally { setIsBusy(false) } @@ -145,7 +164,7 @@ const ResourcesTab = () => { - {pendingRemoval === folder.name ? ( + {!canRemove ? null : pendingRemoval === folder.name ? (
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 6207c3141..2d997f3c2 100644 --- a/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/global-variables-table/selectable-cell.tsx @@ -230,7 +230,7 @@ const SelectableTypeCell = ({ ? '' : definition === 'array' || definition === 'derived' ? cellValue - : (cellValue as unknown as string).toUpperCase()} + : cellValue.toUpperCase()} diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 5abd0cc16..171cc8ea7 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -568,6 +568,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 || '' }) @@ -878,17 +891,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 === 'buildSettings' || - leafLang === 'userManagement' || - leafLang === 'persistentStorage' ? null : ( + {isFixedLeaf ? null : ( 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 991b4edc0..bb44c2d4c 100644 --- a/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx +++ b/src/frontend/components/_organisms/modals/create-graphical-variable-modal.tsx @@ -50,6 +50,7 @@ const CreateGraphicalVariableModal = ({ setName(data.name) setVariableClass('local') setTypeValue(data.suggestedType.value) + setStringLength('') }, [isOpen, data.name, data.suggestedType.value]) /** @@ -76,7 +77,8 @@ const CreateGraphicalVariableModal = ({ } const lengthIsOffered = isLengthQualifiedType(typeValue) - const declaredType = lengthIsOffered && stringLength.trim() !== '' ? `${typeValue}(${stringLength.trim()})` : typeValue + const declaredType = + lengthIsOffered && stringLength.trim() !== '' ? `${typeValue}(${stringLength.trim()})` : typeValue const lengthIsValid = !lengthIsOffered || stringLength.trim() === '' || parseStringLength(declaredType).valid const handleConfirm = () => { diff --git a/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts index 5a87612d8..52e77f8ad 100644 --- a/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts +++ b/src/frontend/utils/PLC/__tests__/sized-string-xml.test.ts @@ -35,17 +35,13 @@ describe('a declared string length over PLCopen XML', () => { }) }) - 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.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 = { diff --git a/src/frontend/utils/PLC/array-codegen-helpers.ts b/src/frontend/utils/PLC/array-codegen-helpers.ts index 2ddc9f524..3e7360606 100644 --- a/src/frontend/utils/PLC/array-codegen-helpers.ts +++ b/src/frontend/utils/PLC/array-codegen-helpers.ts @@ -104,8 +104,7 @@ const GENERIC_TYPE_TO_IEC: Record = { * 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 isDescriptorPinType = (typeName: string): boolean => GENERIC_TYPE_TO_IEC[typeName.toUpperCase()] !== undefined const mapUserTypeToIEC = (typeName: string, userTypeNames?: ReadonlySet): string => { const upper = typeName.toUpperCase() @@ -160,7 +159,11 @@ 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 => { diff --git a/src/frontend/utils/PLC/pou-text-parser.ts b/src/frontend/utils/PLC/pou-text-parser.ts index a8eb087f4..ffa4f234a 100644 --- a/src/frontend/utils/PLC/pou-text-parser.ts +++ b/src/frontend/utils/PLC/pou-text-parser.ts @@ -7,7 +7,7 @@ import { getLanguageFromExtension } from './pou-file-extensions' * @param content - The content to extract documentation from * @returns Object with documentation and remaining content */ -const extractDocumentation = (content: string): { documentation: string; remainingContent: string } => { +export const extractDocumentation = (content: string): { documentation: string; remainingContent: string } => { // 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. 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 fccd17151..376f4380b 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 @@ -388,7 +388,9 @@ describe('parseIecStringToVariables', () => { ['zero', 'STRING(0)'], ['past the implementation maximum', 'STRING(255)'], ])('is refused for %s, rather than becoming a stranger type', (_label, declared) => { - expect(() => parseIecStringToVariables(`VAR\n s : ${declared};\nEND_VAR`)).toThrow(/takes a length from 1 to 254/) + expect(() => parseIecStringToVariables(`VAR\n s : ${declared};\nEND_VAR`)).toThrow( + /takes a length from 1 to 254/, + ) }) // The element form needs `parseArrayType` to admit a length after `OF`; @@ -743,7 +745,6 @@ describe('parseIecStringToVariables', () => { 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 775bbad67..c90985e5e 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -220,5 +220,4 @@ describe('iec-types-registry', () => { expect(isLengthQualifiedType('WString')).toBe(true) }) }) - }) diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index 751fc4407..b42ab47d3 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -141,18 +141,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 data-type text parser (`PLC/data-type-text-parser.ts`). */ +/** + * 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 => { // 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, - ) + 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()) @@ -277,6 +305,24 @@ export const parseIecStringToVariables = ( const parsedType = type.trim() + // A length-qualified string — `STRING(23)`, `WSTRING(8)`. STruC++ emits + // `IECStringVar<23>` at 54 bytes where a plain STRING is 518. Square + // brackets are accepted and normalised to the parenthesised form. + // + // Checked in element position as well as scalar, and before the array + // dispatch, because the two paths fail differently and both fail quietly: + // a bad scalar is stored as a user data type named "STRING(0)", and a bad + // element leaves the whole declaration as one named + // "ARRAY[0..1] OF STRING(0)". Either is emitted verbatim into generated ST. + const arrayElement = /^ARRAY\s*\[[^\]]+\]\s+OF\s+(.+)$/i.exec(parsedType) + const badLength = badStringLength(arrayElement ? arrayElement[1].trim() : parsedType) + if (badLength) { + throw new Error( + `Syntax error on line ${lineNumber}: "${line}". ` + + `${badLength.typeName} takes a length from 1 to ${MAX_STRING_LENGTH}, got "${badLength.got}".`, + ) + } + // Check if it's an array type first const arrayType = parseArrayType(parsedType) if (arrayType) { @@ -306,31 +352,6 @@ export const parseIecStringToVariables = ( ) } - // A length-qualified string — `STRING(23)`, `WSTRING(8)`. STruC++ emits - // `IECStringVar<23>` at 54 bytes where a plain STRING is 518. Square - // brackets are accepted and normalised to the parenthesised form. - // - // Only a malformed or out-of-range length is refused, and refused here - // rather than left to fall through: an unrecognised type is stored as a - // user data type named "STRING(0)" and emitted verbatim into generated ST. - // - // The array element form is handled by `parseArrayType` above. - const stringWithLength = /^(W?STRING)\s*[([]\s*([^)\]]*?)\s*[)\]]$/i.exec(parsedType) - if (stringWithLength) { - // Matching the shape commits to a length, so `STRING[]`, `STRING(abc)`, - // `STRING(0)` and `STRING(999)` are all reported here. `parseStringLength` - // returns `valid: true` with no length for an unqualified name, so the - // undefined case must be caught explicitly. - const { length, valid } = parseStringLength(parsedType) - if (length === undefined || !valid) { - throw new Error( - `Syntax error on line ${lineNumber}: "${line}". ` + - `${stringWithLength[1].toUpperCase()} takes a length from 1 to ${MAX_STRING_LENGTH}, ` + - `got "${stringWithLength[2]}".`, - ) - } - } - const baseCheck = baseTypeSchema.safeParse(parsedType.toUpperCase()) const isUserFunctionBlock = pous?.some( diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index 1d6569e24..8b05dd038 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -121,11 +121,13 @@ export function parseStringLength(name: string): { valid: boolean } { const trimmed = name.trim() - const match = /^([A-Za-z_]\w*)\s*[([]\s*(\d+)\s*[)\]]$/.exec(trimmed) + // 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]) + const length = Number(match[2] ?? match[3]) const valid = LENGTH_QUALIFIED.has(base) && length >= 1 && length <= MAX_STRING_LENGTH return { base, length, valid } } diff --git a/src/main/main.ts b/src/main/main.ts index 759b62d8c..5508c4577 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -180,7 +180,10 @@ const createMainWindow = async () => { // 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) => { + 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) }) diff --git a/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts b/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts index 466c44829..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) => From 5bf9fba58ec0c518a66b17c430e2d8f3c9d1f08b Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Mon, 7 Sep 2026 20:22:14 +1200 Subject: [PATCH 05/13] Follow the class's member name, and hand a block-typed pin its pointer A C++ block fills _VARS by taking the address of the matching class member, and got two cases wrong. A member whose name matches its own type is mangled with a trailing underscore, so `&NODE` named the type rather than the member. The struct field keeps the plain name; only the address follows the class. A pin typed by a function block is an alias for the caller's instance, so that member is already a pointer and taking its address was one indirection too many. The block names come from the project's own POUs and from every installed library. --- .../shared/utils/PLC/preprocess-pous.ts | 15 ++++++++ src/frontend/utils/cpp/generateSTCode.ts | 35 +++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) 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/frontend/utils/cpp/generateSTCode.ts b/src/frontend/utils/cpp/generateSTCode.ts index ec8c0ccf5..e0607490d 100644 --- a/src/frontend/utils/cpp/generateSTCode.ts +++ b/src/frontend/utils/cpp/generateSTCode.ts @@ -10,6 +10,9 @@ import { cBlockExternalVariables, cBlockInterfaceVariables } from './block-inter 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 } /** @@ -41,16 +44,36 @@ type STCodeGenerationParams = { * offset by. Passing the view keeps `lower_bound()` / `upper_bound()` / `at()` * reachable from the block. */ -const generateVariableAssignment = (variable: PLCVariable): string => { +/** + * 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 memberName = (variable: PLCVariable): string => { const name = variable.name.toUpperCase() + 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} = &${name};\n` + 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` } /** @@ -106,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` @@ -114,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 From 71e255fe1bc43ff10792e82158df07d717612d1c Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Tue, 8 Sep 2026 22:01:57 +1200 Subject: [PATCH 06/13] Keep every installed library version, and let placed blocks follow the one a project pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A placed graphical block froze the library's signature into the project the moment it was dropped, so reinstalling a library left every existing call on the old shape with no way to update it. The store held one version per name, and installing overwrote it, so there was nothing to go back to either. The library store now keeps versions side by side, under `//.stlib`, with a registry that lists them per library. A `formatVersion: "1.0"` registry is migrated on read and keeps the paths it already has, so no files move and a half-converted store is not possible. The folder is a sanitised form of the version rather than the version itself: a manifest version is free text, and semver build metadata like `1.0.0+sha.abc` is not a legal path component. The version a project records now reaches the resolver. It was being dropped at the two call sites that turned refs into names, so a project built against whatever happened to be installed. An exact match wins; when the pinned version is absent the newest is used and the build says so, rather than stranding a project on a version nobody has. Uninstall takes an optional version and removes just that one. Placed blocks are re-stamped from the library on load. Pin types, pin classes that stay on the same side, the block's documentation and its extensible flag are applied; a pin the library added grows the block when the caller can measure it. A pin the library removed, a class change that moves a pin to the other side, and a change of block kind are reported and deliberately not applied — each of those invalidates existing wiring, and repairing a diagram is the user's call. An extensible block's extra pins are left alone: ADD's IN3 belongs to the diagram, not the library. A variable wired to a pin keeps its own copy of that pin's signature, and that copy is what Ladder renders as the `(*TYPE*)` placeholder and validates dropped variables against, so it is re-stamped alongside the block. FBD needs none of this — it resolves a pin from the connected block on every render — and ST and IL hold no signature at all. The refresh is written into `pou.body.value`, not only into the canvas flow. Only the flow is drawn; everything that saves or compiles reads the body, so refreshing one and not the other threw the work away on save and repeated it on every load. The project is left unsaved when anything changed, so the refresh reaches disk. The Library Manager shows the versions a library has installed and can remove one, a project library can be pinned to any of them, and a dialog offers the newer versions when a project opens, after an install, or on demand — repinning and re-stamping together so the pin and the diagrams cannot disagree. Separately, a resource library's `depends=` now becomes an `#include` in `defines.h`. Those sources are precompiled into an archive and moved aside before the sketch links, so arduino-cli's include scan never sees what they need, and the archive compiled and then failed to link. The header comes from the depended library's own `includes=` where that library is one of ours, and from the `.h` convention otherwise. The C-blocks header also spells a pin with every enabled library's type names while the `using` aliases stay project-only, because the compiler only declares a library's type when something names it. --- .../editor/compiler/compiler-module.ts | 21 +- .../compiler/desktop-library-build-port.ts | 6 +- .../__tests__/library-manager-module.test.ts | 167 +++++- .../__tests__/registry-versions.test.ts | 80 +++ .../library-manager/library-manager-module.ts | 145 +++-- .../library-manager/registry-versions.ts | 115 ++++ src/backend/editor/library-manager/types.ts | 33 +- src/backend/shared/compile/pipeline.ts | 82 ++- .../compile/steps/compose-firmware-bundle.ts | 14 +- .../shared/compile/steps/generate-defines.ts | 21 + src/cli/compile/headless-bridge.ts | 6 +- .../__tests__/project-libraries-tab.test.tsx | 71 +++ .../library-manager/project-libraries-tab.tsx | 85 ++- .../reconcile-placed-blocks.ts | 74 +++ .../library-manager/system-libraries-tab.tsx | 78 ++- .../__tests__/library-updates-modal.test.tsx | 74 +++ .../modals/library-updates-modal.tsx | 139 +++++ .../components/_templates/app-layout.tsx | 2 + .../library-block-resolution.test.ts | 319 +++++++++++ .../store/__tests__/library-slice.test.ts | 100 ++++ src/frontend/store/slices/library/slice.ts | 101 +++- src/frontend/store/slices/library/types.ts | 25 + src/frontend/store/slices/modal/slice.ts | 1 + src/frontend/store/slices/modal/types.ts | 2 + src/frontend/store/slices/shared/slice.ts | 53 +- .../restamp-library-variants.test.ts | 537 +++++++++++++++++- .../utils/PLC/restamp-library-variants.ts | 466 +++++++++++++-- src/main/modules/ipc/main.ts | 9 +- src/main/modules/ipc/renderer.ts | 4 +- .../editor/__tests__/library-adapter.test.ts | 8 +- .../adapters/editor/library-adapter.ts | 4 +- src/middleware/shared/ports/block-types.ts | 7 + src/middleware/shared/ports/library-port.ts | 5 +- src/middleware/shared/ports/library-types.ts | 28 + 34 files changed, 2682 insertions(+), 200 deletions(-) create mode 100644 src/backend/editor/library-manager/__tests__/registry-versions.test.ts create mode 100644 src/backend/editor/library-manager/registry-versions.ts create mode 100644 src/frontend/components/_features/[workspace]/editor/library-manager/__tests__/project-libraries-tab.test.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/library-manager/reconcile-placed-blocks.ts create mode 100644 src/frontend/components/_organisms/modals/__tests__/library-updates-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/library-updates-modal.tsx create mode 100644 src/frontend/store/__tests__/library-block-resolution.test.ts diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index f7b8c8215..9f46ee3fc 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -41,6 +41,7 @@ import { } 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, @@ -76,7 +77,7 @@ type LibraryCompileBridge = { cleanBuild: boolean onUploadAccepted?: (responseBody: string) => void }) => Promise<{ success: true; data: string } | { success: false; error: string }> - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives } type LibraryVerificationBridge = LibraryCompileBridge @@ -2940,7 +2941,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() @@ -3073,6 +3074,7 @@ class CompilerModule { let devicePinMapping: DevicePin[] let libraryArchives: unknown[] let missingLibraries: string[] + let substitutedLibraries: VersionSubstitution[] let avrLibStdCppInclude = '' try { firmwareSkeleton = await this.loadFirmwareSkeletonInMemory(boardRuntime) @@ -3280,10 +3282,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() @@ -3472,6 +3474,7 @@ class CompilerModule { compileOnly: compileOnly ?? false, libraryArchives, missingLibraries, + substitutedLibraries, firmwareSkeleton, strucppRuntimeHeaders, avrLibStdCppInclude, @@ -3577,7 +3580,7 @@ class CompilerModule { args: Array, _mainProcessPort: CompileProgressChannel, mainProcessBridge: { - loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } + loadEnabledArchives: (refs: ReadonlyArray) => EnabledArchives }, ): Promise { _mainProcessPort.start() @@ -3696,9 +3699,9 @@ class CompilerModule { // Resolved once, outside the compile step: the C-blocks header and code // below need the same archives, to spell a pin typed by a library's own // data type the way strucpp declared it. - const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) - const { archives: libraries, missing: missingLibraries } = - mainProcessBridge.loadEnabledArchives(enabledLibraryNames) + const { archives: libraries, missing: missingLibraries } = mainProcessBridge.loadEnabledArchives( + projectData.libraries ?? [], + ) const typeNames = projectAndLibraryTypeNames(projectData, libraries) // Compile ST to C++ with STruC++ (replaces iec2c + debug + glue generation) @@ -3853,7 +3856,7 @@ class CompilerModule { // 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), diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index 1768e8394..e2bda954a 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -29,6 +29,8 @@ import { import type { TranspileToStArgs, TranspileToStResult } from '@root/middleware/shared/ports/compiler-platform-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. * Injected (not imported as a module reference) so this file's @@ -42,7 +44,7 @@ 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 @@ -177,7 +179,7 @@ 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 }) { 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..5c9953462 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, }) @@ -246,7 +253,7 @@ describe('LibraryManagerModule', () => { // Mock derives the name from the file basename, sanitised. if (result.success && !('canceled' 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 +321,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 +349,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 +372,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 +434,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 +459,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 +478,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 +506,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 +537,132 @@ 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('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..bef8c65bd 100644 --- a/src/backend/editor/library-manager/library-manager-module.ts +++ b/src/backend/editor/library-manager/library-manager-module.ts @@ -1,10 +1,16 @@ 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, + LibraryInstallResult, + LibraryRef, + 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 { @@ -16,6 +22,13 @@ import { downloadPublicLibrary } from '../../shared/library/public-catalog-clien 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' /** @@ -168,16 +181,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 +220,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 +262,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 +301,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 +397,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,11 +405,31 @@ 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` } } + // 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` } + } + validatePathId(version, 'version') + const versionDir = join(this.librariesDir, name, version) + assertPathContained(this.librariesDir, versionDir, 'library install path') + // A version migrated from the old layout shares the library directory + // with its siblings, so remove just its archive. + const target = existsSync(versionDir) ? versionDir : entry.stlibPath + 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] + this.writeRegistry(registry) + return { success: true } + } + const libraryDir = join(this.librariesDir, name) assertPathContained(this.librariesDir, libraryDir, 'library install path') if (existsSync(libraryDir)) { @@ -455,15 +514,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 +622,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..557f0084a --- /dev/null +++ b/src/backend/editor/library-manager/registry-versions.ts @@ -0,0 +1,115 @@ +/** + * 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/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index df3d7338f..6befe7e06 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -26,6 +26,7 @@ import type { PlatformDeviceContext, PlatformLog, } 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 { resolveTargetCapabilities } from '../../../middleware/shared/utils/target-capabilities' @@ -191,6 +192,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- @@ -420,6 +424,57 @@ function collectLibraryResources( })) } +/** + * 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, @@ -456,6 +511,7 @@ async function runCompilePipelineInner( compileOnly, libraryArchives, missingLibraries, + substitutedLibraries, firmwareSkeleton, strucppRuntimeHeaders, avrLibStdCppInclude, @@ -505,6 +561,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) { @@ -635,7 +704,8 @@ async function runCompilePipelineInner( // The enabled libraries' data types count as well as the project's: a pin // typed by one has to be spelled the way strucpp declared it. const userTypeNames = projectAndLibraryTypeNames(projectData, libraryArchives) - const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames) + const ownTypeNames = (projectData.dataTypes ?? []).map((dataType) => dataType.name) + const cBlocks = buildCBlocksFromPous(originalCppPous as never, userTypeNames, ownTypeNames) const bundle = composeRuntimeV4Bundle({ programSt, md5, @@ -943,6 +1013,7 @@ async function runCompilePipelineInner( boardRuntime, ...(vppModbusState !== undefined ? { vppModbusState } : {}), ...(strucppResult.retainBlobSize !== null ? { retainBlobSize: strucppResult.retainBlobSize } : {}), + resourceLibraryDepends: resourceLibraryDepends(libraryResources), }) // VPP config header — emitted only for arduino-cli targets whose @@ -959,8 +1030,13 @@ 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, diff --git a/src/backend/shared/compile/steps/compose-firmware-bundle.ts b/src/backend/shared/compile/steps/compose-firmware-bundle.ts index 2ea04e88f..6f2941687 100644 --- a/src/backend/shared/compile/steps/compose-firmware-bundle.ts +++ b/src/backend/shared/compile/steps/compose-firmware-bundle.ts @@ -99,9 +99,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 @@ -115,7 +127,7 @@ export function buildCBlocksFromPous( })) return { header: generateCBlocksHeader(headers, userTypeNames), - code: generateCBlocksCode(originalCppPous, userTypeNames), + code: generateCBlocksCode(originalCppPous, aliasTypeNames), } } diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 521e33952..90259ebc9 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -92,6 +92,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[] } /** @@ -123,6 +132,7 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { vppModbusState, defaultSerial, retainBlobSize, + resourceLibraryDepends, } = input let DEFINES_CONTENT = '' @@ -314,5 +324,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/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/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 698cad340..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() + }} /> ))} @@ -199,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 +215,30 @@ const SystemLibrariesTab = ({ installed, onRefresh }: SystemLibrariesTabProps) =
- + {selectedVersions.length > 1 ? ( +
+ + Versions + + {selectedVersions.map((version) => ( +
+ + {version} + + +
+ ))} +
+ ) : ( + + )} ({ + ...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/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/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index f06600f3f..158c5e8d5 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -24,6 +24,7 @@ import { CreateGraphicalVariableModal } from '../_organisms/modals/create-graphi import { DebuggerIpInputModal } from '../_organisms/modals/debugger-ip-input-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' +import { LibraryUpdatesModal } from '../_organisms/modals/library-updates-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' import { ProjectReadmeModal } from '../_organisms/modals/project-readme-modal' import { PublicCatalogBrowserModal } from '../_organisms/modals/public-catalog-browser-modal' @@ -161,6 +162,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/store/__tests__/library-block-resolution.test.ts b/src/frontend/store/__tests__/library-block-resolution.test.ts new file mode 100644 index 000000000..aae1fdbb3 --- /dev/null +++ b/src/frontend/store/__tests__/library-block-resolution.test.ts @@ -0,0 +1,319 @@ +/** + * 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) + }) +}) diff --git a/src/frontend/store/__tests__/library-slice.test.ts b/src/frontend/store/__tests__/library-slice.test.ts index f0bd68fec..7bb3d56f8 100644 --- a/src/frontend/store/__tests__/library-slice.test.ts +++ b/src/frontend/store/__tests__/library-slice.test.ts @@ -354,4 +354,104 @@ 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/library/slice.ts b/src/frontend/store/slices/library/slice.ts index 64e2a9c64..6d41bf3b1 100644 --- a/src/frontend/store/slices/library/slice.ts +++ b/src/frontend/store/slices/library/slice.ts @@ -1,8 +1,9 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' +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 @@ -38,9 +39,11 @@ const createLibrarySlice: StateCreator = (se system: [], user: [], }, + installedLibraries: [], enabledLibraries: [], bundledLibraryNames: [], missingLibraries: [], + outdatedLibraries: [], libraryActions: { setSystemLibraries: (libraries) => { // Strip malformed POU entries at the source — keep only @@ -56,13 +59,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) }), ) }, @@ -147,8 +152,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) }), ) }, @@ -163,6 +187,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 f42827973..40509712e 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 @@ -60,6 +72,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()` — @@ -70,6 +89,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. */ @@ -82,6 +105,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 7e7843631..cf7f2bfdb 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 a5efaeb31..e70d89caf 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 4cb78134f..7b67b0d23 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -14,7 +14,11 @@ import { isLegalIdentifier } from '../../../utils/keywords' import { newUuid } from '../../../utils/new-uuid' import { findGlobalVariableListReferences } from '../../../utils/PLC/global-variable-list-references' import { globalVariableListTypeName } from '../../../utils/PLC/global-variable-list-serializer' -import { restampFlowLibraryVariants } from '../../../utils/PLC/restamp-library-variants' +import { + type RestampChange, + restampFlowLibraryVariants, + summariseRestampChanges, +} from '../../../utils/PLC/restamp-library-variants' import { collectAllSlaveNames, generateUniqueSlaveName } from '../../../utils/unique-slave-name' import type { FBDFlowType } from '../fbd' import type { FileSliceDataObject } from '../file' @@ -1275,7 +1279,9 @@ const createSharedSlice: StateCreator = (s // no-op when the libraries haven't loaded yet or nothing is stale. const systemLibraries = getState().libraries.system const userPouNames = pous.filter((pou) => pou.pouType !== 'program').map((pou) => pou.name) - let restampedCount = 0 + 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, @@ -1303,25 +1309,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 += restampFlowLibraryVariants([bodyValue], systemLibraries, userPouNames) + const report = restampFlowLibraryVariants([bodyValue], systemLibraries, userPouNames, { 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 += restampFlowLibraryVariants([bodyValue], systemLibraries, userPouNames) + const report = restampFlowLibraryVariants([bodyValue], systemLibraries, userPouNames, { 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} library block pin type(s) from the current library 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', @@ -1370,6 +1405,10 @@ const createSharedSlice: StateCreator = (s // a clear error if they don't install the missing pieces. 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') } // Reclassify ALL POUs' variables with full context. diff --git a/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts index 112ff8c57..b58b3461c 100644 --- a/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts +++ b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts @@ -1,12 +1,20 @@ +import { produce } from 'immer' + import type { SystemLibrary } from '../../../../middleware/shared/ports/library-types' -import { restampFlowLibraryVariants } from '../restamp-library-variants' +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(): SystemLibrary[] { +function makeSystemLibraries(overrides: Partial> = {}): SystemLibrary[] { return [ { name: 'STANDARD_FUNCTIONS', @@ -20,7 +28,8 @@ function makeSystemLibraries(): SystemLibrary[] { variables: [ { name: 'OUT', class: 'output', type: { definition: 'base-type', value: '__XWORD' } }, { name: 'IN', class: 'input', type: { definition: 'generic-type', value: 'ANY' } }, - ], + ] as LibVar[], + ...overrides, }, ], }, @@ -33,6 +42,7 @@ function makeStaleAdrNode() { id: 'block-1', type: 'block', data: { + variable: { name: 'ADR0' }, variant: { name: 'ADR', type: 'function', @@ -48,6 +58,31 @@ function makeStaleAdrNode() { } } +/** 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 +} + +const applied = (changes: RestampChange[]) => changes.filter((change) => change.applied) +const kindOf = (changes: RestampChange[], kind: string) => changes.find((change) => change.kind === kind) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -57,9 +92,10 @@ describe('restampFlowLibraryVariants', () => { const node = makeStaleAdrNode() const flow = { rung: { nodes: [node] } } - const changed = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) - expect(changed).toBe(1) + 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') }) @@ -67,21 +103,19 @@ describe('restampFlowLibraryVariants', () => { const node = makeStaleAdrNode() const flow = { rungs: [{ nodes: [] }, { nodes: [node] }] } - const changed = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) - expect(changed).toBe(1) + expect(applied(changes)).toHaveLength(1) expect(node.data.variant.variables[0].type.value).toBe('__XWORD') }) it('skips blocks backed by a user-defined POU', () => { - // A user POU named "ADR" (contrived) must NOT be re-stamped even though a - // library entry of the same name exists. const node = makeStaleAdrNode() const flow = { rung: { nodes: [node] } } - const changed = restampFlowLibraryVariants([flow], makeSystemLibraries(), ['ADR']) + const { changes } = restampFlowLibraryVariants([flow], makeSystemLibraries(), ['ADR']) - expect(changed).toBe(0) + expect(changes).toHaveLength(0) expect(node.data.variant.variables[0].type.value).toBe('ULINT') }) @@ -90,9 +124,7 @@ describe('restampFlowLibraryVariants', () => { node.data.variant.variables[0].type.value = '__XWORD' const flow = { rung: { nodes: [node] } } - const changed = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(0) + expect(restampFlowLibraryVariants([flow], makeSystemLibraries(), []).changes).toHaveLength(0) }) it('ignores blocks not present in any library (user blocks, unknown types)', () => { @@ -100,19 +132,484 @@ describe('restampFlowLibraryVariants', () => { node.data.variant.name = 'MY_CUSTOM_FB' const flow = { rung: { nodes: [node] } } - const changed = restampFlowLibraryVariants([flow], makeSystemLibraries(), []) - - expect(changed).toBe(0) + expect(restampFlowLibraryVariants([flow], makeSystemLibraries(), []).changes).toHaveLength(0) expect(node.data.variant.variables[0].type.value).toBe('ULINT') }) - it('is a no-op when no libraries are loaded yet', () => { + it('reports an empty pool rather than a clean project', () => { const node = makeStaleAdrNode() const flow = { rung: { nodes: [node] } } - const changed = restampFlowLibraryVariants([flow], [], []) + const report = restampFlowLibraryVariants([flow], [], []) - expect(changed).toBe(0) + 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']) + }) }) diff --git a/src/frontend/utils/PLC/restamp-library-variants.ts b/src/frontend/utils/PLC/restamp-library-variants.ts index 860041e64..4000f8553 100644 --- a/src/frontend/utils/PLC/restamp-library-variants.ts +++ b/src/frontend/utils/PLC/restamp-library-variants.ts @@ -1,117 +1,475 @@ import type { BlockVariant } from '@root/middleware/shared/ports/block-types' import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' +import { blockParameterSide } from '../graphical/in-out-pin-rules' + /** - * Refresh the *types* carried by placed graphical block variants from the - * current library definitions. + * 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. On project load + * we re-stamp every block that resolves to a library definition; blocks backed + * by a user-defined POU are skipped, because the project owns their interface. * - * 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 a library updates a block's pin or - * return type — e.g. `ADR` moving from `ULINT` to the platform-width `__XWORD` - * — already-placed blocks keep the stale type and the transpiler (which reads - * `node.data.variant` via `collect-library-blocks`) emits the old type. + * What is applied in place: pin `type`, pin `class` that stays on the same + * side, `documentation`, `extensible` and the block `type`. None of those move + * a pin. * - * On project load we re-stamp the variable types of every block that resolves - * to a **library** definition (the bundled / system libraries in - * `libraries.system`). Blocks backed by a **user-defined POU** (a function or - * function-block authored in this project) are skipped — their interface lives - * in the project, not the library, so the project is the source of truth. + * 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. * - * The refresh is intentionally type-only: it matches variables by name and - * copies the library's `type`, leaving the pin set, ids, handles and wiring - * untouched. That keeps extensible (variadic) blocks and existing connections - * intact while still propagating type changes. + * 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] -/** Index every library POU by name → its variables, for O(1) lookup. */ -function indexLibraryPous(systemLibraries: SystemLibrary[]): Map { - const byName = new Map() +/** 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. */ +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. if (!byName.has(pou.name)) byName.set(pou.name, pou) } } return byName } -/** A minimal block-bearing node shape; both FBD and LD nodes satisfy it. */ -type BlockBearingNode = { type?: string; data?: { variant?: BlockVariant } } +type FlowEdge = { + source?: string + target?: string + sourceHandle?: string | null + targetHandle?: string | null +} + +type Handle = { glbPosition?: { x: number; y: number } } /** - * Re-stamp variable types in place for every library-backed block node. - * - * @param nodes the flow's nodes (FBD `rung.nodes` or an LD - * `rung.nodes`) - * @param libraryPousByName library POUs indexed by name - * @param userPouNames names of user-defined POUs to skip (uppercased) - * @returns the number of variables actually changed (for logging/tests) + * 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 +} + function restampNodes( nodes: BlockBearingNode[], - libraryPousByName: Map, + edges: FlowEdge[], + libraryPousByName: Map, userPouNames: Set, -): number { - let changed = 0 + pou: string | undefined, + measureBlock: MeasureBlock | undefined, + changes: RestampChange[], +): boolean { + let modified = false + // Block node id -> its pins as the library now declares them, so the + // variables wired to those pins can be brought along. + const refreshedPins = new Map>() + for (const node of nodes) { if (node?.type !== 'block') continue const variant = node.data?.variant const name = variant?.name if (!variant || !name) continue - // Skip blocks backed by a user-defined POU — the project owns their shape. + // The project owns a user-defined POU's shape. if (userPouNames.has(name.toUpperCase())) continue const libPou = libraryPousByName.get(name) if (!libPou) continue - // Index the library's variables by name for matching. - const libVarByName = new Map(libPou.variables.map((v) => [v.name, v])) + 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, variable])) + if (node.id) refreshedPins.set(node.id, libVarByName) + 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)) continue + seen.add(variable.name) + const connected = isPinConnected(node, variable.name, edges) const libVar = libVarByName.get(variable.name) - if (!libVar) continue + + 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) continue - // The block-variant type union only admits 'base-type' / 'generic-type'; - // library `typeRef()` emits exactly those, so the shape is compatible. - variable.type = { definition: next.definition, value: next.value } as VariantVariable['type'] - changed += 1 + 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) && !seen.has(libVar.name)) + // 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 changed + + // A variable wired to a pin carries its own copy of that pin's signature. + // Leaving it behind shows the old type on the canvas and rejects a variable + // of the new one, which reads as the update having done nothing. + for (const node of nodes) { + const attached = node.data?.block + if (!attached?.id || !attached.handleId || !attached.variableType) continue + const libVar = refreshedPins.get(attached.id)?.get(attached.handleId) + if (!libVar) continue + const current = attached.variableType + if ( + current.name === libVar.name && + current.class === libVar.class && + current.type?.definition === libVar.type.definition && + current.type?.value === libVar.type.value + ) { + continue + } + attached.variableType = { name: libVar.name, class: libVar.class, type: { ...libVar.type } } + modified = true + } + + return modified } /** * Re-stamp every block in the given flows from the current system libraries. - * Mutates the flow objects in place. Returns the total number of variable - * types changed (0 when nothing was stale). + * 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 and on the shared - * surface. + * shape is duck-typed so the helper stays language-agnostic. */ export function restampFlowLibraryVariants( - flows: Array<{ rung?: { nodes?: unknown }; rungs?: Array<{ nodes?: unknown }> }>, + flows: Array<{ rung?: RungLike; rungs?: RungLike[] }>, systemLibraries: SystemLibrary[], userPouNames: Iterable, -): number { + options: RestampOptions = {}, +): RestampReport { const libraryPousByName = indexLibraryPous(systemLibraries) - if (libraryPousByName.size === 0) return 0 + if (libraryPousByName.size === 0) return { changes: [], poolEmpty: true, modified: false } + const skip = new Set() - for (const n of userPouNames) skip.add(n.toUpperCase()) + for (const userPouName of userPouNames) skip.add(userPouName.toUpperCase()) - let changed = 0 + 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)) changed += restampNodes(nodes as BlockBearingNode[], libraryPousByName, skip) + if (!Array.isArray(nodes)) continue + const edges = Array.isArray(rung?.edges) ? (rung.edges as FlowEdge[]) : [] + const touched = restampNodes( + nodes as BlockBearingNode[], + edges, + libraryPousByName, + skip, + options.pou, + options.measureBlock, + changes, + ) + modified = modified || touched } } - return changed + return { changes, poolEmpty: false, 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': + return ( + `${block}: the library added pin ${pin} (${to}). ${plural(count, 'placed block')}${at} ` + + `${verb(count, 'does not draw', 'do not draw')} it yet.` + ) + 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/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index f3a6927e5..9a449314e 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -93,6 +93,7 @@ import { } from '../../../backend/editor/utils' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' +import type { EnabledArchives, LibraryRef } from '../../../middleware/shared/ports/library-types' import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' /** Why a channel could not be handed out. */ @@ -1207,8 +1208,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') } @@ -1347,8 +1348,8 @@ class MainProcessBridge implements MainIpcModule { * and the library build (compileStlib's dependency list) so the * verify pass 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) => { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index f744673a0..b42de7222 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -195,8 +195,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, 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/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/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/library-port.ts b/src/middleware/shared/ports/library-port.ts index e652b0c79..5fe78b5ea 100644 --- a/src/middleware/shared/ports/library-port.ts +++ b/src/middleware/shared/ports/library-port.ts @@ -152,8 +152,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..9cbceaa37 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 @@ -148,3 +152,27 @@ export type LibraryInstallResult = } | { success: true; canceled: true } | { success: false; 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[] +} From 1e9be180b473eab90f3b073b0b7148c87d8785d2 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Tue, 8 Sep 2026 22:20:40 +1200 Subject: [PATCH 07/13] Run the formatter over the library-version work Four files went in unformatted: a signature Prettier keeps on one line, and three test files where a call chain wraps differently. No behaviour changes. --- .../library-manager/registry-versions.ts | 5 +-- .../library-block-resolution.test.ts | 8 ++-- .../store/__tests__/library-slice.test.ts | 10 +++-- .../restamp-library-variants.test.ts | 38 +++++++++++-------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/backend/editor/library-manager/registry-versions.ts b/src/backend/editor/library-manager/registry-versions.ts index 557f0084a..1584e8c20 100644 --- a/src/backend/editor/library-manager/registry-versions.ts +++ b/src/backend/editor/library-manager/registry-versions.ts @@ -83,10 +83,7 @@ export interface ResolvedVersion { * 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 { +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 diff --git a/src/frontend/store/__tests__/library-block-resolution.test.ts b/src/frontend/store/__tests__/library-block-resolution.test.ts index aae1fdbb3..c6d3840e3 100644 --- a/src/frontend/store/__tests__/library-block-resolution.test.ts +++ b/src/frontend/store/__tests__/library-block-resolution.test.ts @@ -65,9 +65,7 @@ function library(version = '0.1.0', pins = COUNTER_PINS, documentation = 'v1 doc version, stPath: '', cPath: '', - pous: [ - { name: 'COUNTER_FB', type: 'function-block', language: 'st', body: '', documentation, variables: pins }, - ], + pous: [{ name: 'COUNTER_FB', type: 'function-block', language: 'st', body: '', documentation, variables: pins }], } as unknown as SystemLibrary } @@ -260,7 +258,9 @@ describe('a placed library block after project open', () => { 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') + 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. diff --git a/src/frontend/store/__tests__/library-slice.test.ts b/src/frontend/store/__tests__/library-slice.test.ts index 7bb3d56f8..2e69a9ffc 100644 --- a/src/frontend/store/__tests__/library-slice.test.ts +++ b/src/frontend/store/__tests__/library-slice.test.ts @@ -446,10 +446,12 @@ describe('createLibrarySlice', () => { }) 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' }), - ]) + 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/utils/PLC/__tests__/restamp-library-variants.test.ts b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts index b58b3461c..62c730e0f 100644 --- a/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts +++ b/src/frontend/utils/PLC/__tests__/restamp-library-variants.test.ts @@ -1,11 +1,7 @@ import { produce } from 'immer' import type { SystemLibrary } from '../../../../middleware/shared/ports/library-types' -import { - type RestampChange, - restampFlowLibraryVariants, - summariseRestampChanges, -} from '../restamp-library-variants' +import { type RestampChange, restampFlowLibraryVariants, summariseRestampChanges } from '../restamp-library-variants' // --------------------------------------------------------------------------- // Factory helpers @@ -175,9 +171,13 @@ describe('restampFlowLibraryVariants', () => { expect(node.data.variant.type).toBe('function') }) - it('leaves an extensible block\'s extra pins alone', () => { + 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' } }) + 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, []) @@ -420,7 +420,9 @@ describe('restampFlowLibraryVariants', () => { it('stamps the POU name onto every change when given one', () => { const node = makeStaleAdrNode() - const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], makeSystemLibraries(), [], { pou: 'main' }) + const { changes } = restampFlowLibraryVariants([{ rung: { nodes: [node] } }], makeSystemLibraries(), [], { + pou: 'main', + }) expect(changes.every((change) => change.pou === 'main')).toBe(true) }) @@ -432,7 +434,10 @@ describe('FBD flows', () => { // 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 }[] }, + 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)! @@ -445,7 +450,11 @@ describe('FBD flows', () => { 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 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' }], @@ -539,12 +548,9 @@ describe('pin type transitions', () => { 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' }, - ) + 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 }) From ca57f629cf1ded3ecee41c1897508f5190a9749d Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Fri, 11 Sep 2026 15:45:42 +1200 Subject: [PATCH 08/13] feat(cli): reach the library version model from openplc-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Versions install side by side and a project pins the one it compiles against, but the CLI could only build, install and list — so clearing a test library meant deleting directories and hand-editing registry.json, and proving a pin changes the output meant hand-editing project.json. Adds: library uninstall [@] [--all] library info [@] library pin @ library unpin and gives `list` a column for every installed version — listInstalled already returned them and the command threw them away. The JSON carries the full array whatever the table shows. A version is named with @, not a flag: --version is global and prints the CLI's own version before a command runs. Fixes found while wiring these up: - A version with semver build metadata could never be uninstalled. uninstall built its folder from the raw version and ran it through validatePathId, which rejects '+' — but installFromFile accepts 1.0.0+sha.abc and stores it under a sanitised folder. The check also ran after the entry lookup, so it failed on a version that demonstrably existed. The folder now comes from the registry entry. - Uninstalling the last version left an empty library directory behind. - `info` on a version that is not installed printed another version's blocks under the heading asked for. readArchiveText resolves through resolveVersion, which substitutes the newest — right for a compile, which reports the substitution, wrong for a command asked about one version. Checked before the read. - `library install` reported invalid_argument with ExitCode.TargetError; the argument parsed, the module refused, so it is target_error. loadProject now hydrates the library pool before handleOpenProjectResponse — that action restamps every placed block against libraries.system in the same call, so a pool hydrated afterwards is one the restamp never saw. A damaged library store warns instead of failing the load. compile gains correct missing-library reporting from the same change. Repinning reports what it does to placed blocks rather than rewriting them: applying a library-added pin needs the language's own getBlockSize, which lives in the components layer the CLI may not import, and the GUI reconciles on the next open. Pin types the compiler honours immediately either way. withProjectLibraries replaces data.libraries in project.json and leaves the rest of the document alone. saveLibraryManagerOnly now shares it, so the GUI and the CLI cannot write the field differently. Adds the Libraries section docs/CLI.md never had — build and install were undocumented there too. --- docs/CLI.md | 83 ++++++ .../__tests__/library-manager-module.test.ts | 39 +++ .../library-manager/library-manager-module.ts | 26 +- src/cli/__tests__/library.test.ts | 209 +++++++++++++- src/cli/__tests__/load-hydration.test.ts | 119 ++++++++ src/cli/commands/library-pin.ts | 240 ++++++++++++++++ src/cli/commands/library.ts | 258 +++++++++++++++++- src/cli/main.ts | 4 + src/cli/project/load.ts | 37 ++- src/frontend/services/save-actions.ts | 27 +- .../__tests__/project-libraries-json.test.ts | 103 +++++++ .../utils/PLC/project-libraries-json.ts | 51 ++++ 12 files changed, 1148 insertions(+), 48 deletions(-) create mode 100644 src/cli/__tests__/load-hydration.test.ts create mode 100644 src/cli/commands/library-pin.ts create mode 100644 src/frontend/utils/PLC/__tests__/project-libraries-json.test.ts create mode 100644 src/frontend/utils/PLC/project-libraries-json.ts diff --git a/docs/CLI.md b/docs/CLI.md index f16f0a39e..2d5617c55 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` | @@ -215,6 +216,88 @@ 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. + +### 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 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 5c9953462..b2e4aeaec 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 @@ -620,6 +620,45 @@ describe('LibraryManagerModule', () => { 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') diff --git a/src/backend/editor/library-manager/library-manager-module.ts b/src/backend/editor/library-manager/library-manager-module.ts index bef8c65bd..d2758e830 100644 --- a/src/backend/editor/library-manager/library-manager-module.ts +++ b/src/backend/editor/library-manager/library-manager-module.ts @@ -410,28 +410,36 @@ export class LibraryManagerModule { 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` } } - validatePathId(version, 'version') - const versionDir = join(this.librariesDir, name, version) - assertPathContained(this.librariesDir, versionDir, 'library install path') - // A version migrated from the old layout shares the library directory - // with its siblings, so remove just its archive. - const target = existsSync(versionDir) ? versionDir : entry.stlibPath + // 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] + 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 } } - const libraryDir = join(this.librariesDir, name) - assertPathContained(this.librariesDir, libraryDir, 'library install path') if (existsSync(libraryDir)) { rmSync(libraryDir, { recursive: true }) } diff --git a/src/cli/__tests__/library.test.ts b/src/cli/__tests__/library.test.ts index 54696a01e..517d125cb 100644 --- a/src/cli/__tests__/library.test.ts +++ b/src/cli/__tests__/library.test.ts @@ -22,17 +22,48 @@ 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: () => [ - { name: 'modbee-protocol', version: '0.1.0', bundled: false, installedAt: '', origin: 'stlib' }, - ], + 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[] = [] @@ -47,11 +78,11 @@ const run = async (argv: string[]) => { } describe('openplc-cli library', () => { - it('names the three subcommands when given none', async () => { + 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 or list') + expect(payload.error.message).toContain('build, install, uninstall, info, list, pin or unpin') }) it('rejects an unknown subcommand rather than guessing', async () => { @@ -79,4 +110,172 @@ describe('openplc-cli library', () => { 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..53f85d358 --- /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 userPouNames = state.project.data.pous.map((pou) => pou.name) + + 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, userPouNames, { 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 index 39fff3936..e211a73da 100644 --- a/src/cli/commands/library.ts +++ b/src/cli/commands/library.ts @@ -1,6 +1,7 @@ /** - * `openplc-cli library` — build a Library Project into a `.stlib`, install one, - * and list what is installed. + * `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 @@ -18,6 +19,7 @@ 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' @@ -25,6 +27,7 @@ import { createHeadlessCompileBridge, createProgressChannel } from '../compile/h 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 @@ -34,19 +37,40 @@ export async function runLibrary(args: ParsedArgs, reporter: Reporter): Promise< 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 or list — got "${subcommand ?? ''}".`, + 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, @@ -222,13 +246,15 @@ async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefin ) } + // `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.InvalidArgument, message: result.error }, ExitCode.TargetError) + return reporter.failure({ code: ErrorCode.TargetError, message: result.error }, ExitCode.TargetError) } if (result.canceled) { return reporter.failure( - { code: ErrorCode.InvalidArgument, message: `Nothing installed from ${stlibPath}.` }, + { code: ErrorCode.TargetError, message: `Nothing installed from ${stlibPath}.` }, ExitCode.TargetError, ) } @@ -241,12 +267,220 @@ async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefin function runLibraryList(reporter: Reporter): CliResult { const installed = new LibraryManagerModule().listInstalled() - return reporter.success({ ok: true, libraries: installed }, () => - installed.length === 0 - ? 'No libraries installed.' - : renderTable( - ['Name', 'Version', 'Origin'], - installed.map((library) => [library.name, library.version, library.origin]), - ), + // 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/main.ts b/src/cli/main.ts index 52cbfac2a..8d5881edc 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -82,7 +82,11 @@ Usage openplc-cli devices [--timeout ] openplc-cli library build [--clean] 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 openplc-cli compile [--target ] [--port ] [--clean] openplc-cli upload (--host
| --port ) [--target ] [--clean] [-y|--yes] openplc-cli debug open --target (--host
| --port ) [--upload-if-needed] diff --git a/src/cli/project/load.ts b/src/cli/project/load.ts index de6813cd5..04fb37567 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, ): 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') { @@ -1180,24 +1177,12 @@ async function saveLibraryManagerOnly( 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) } /** 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/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) } +} From 392b4e5e3e1cecd9c89ac32701e924f18461f35f Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Mon, 14 Sep 2026 09:21:03 +1200 Subject: [PATCH 09/13] Install every library in a ZIP, not one file at a time The Library Manager took one file per trip through the picker. A library ships as several files more often than not -- a vendor set, or one library built for several versions -- so the picker now accepts a .zip and installs every .stlib, .lib and .library inside it, at any depth. Reading the ZIP is a new pure module beside the other shared library code: bytes in, library files out, no filesystem and no strucpp. The two formats cannot share a representation, so it returns a discriminated union -- a .stlib as text, a CODESYS file as bytes, since reading a binary .lib through `string` would mangle it before the importer saw it. Each file then goes through the same preparer it would have on its own, so a file in a ZIP is installed exactly as picking it directly would be. installFromCodesysBytes is split out of installFromCodesys for that, mirroring the installFromText that already existed for archives retrieved from a device. One bad file does not sink the rest: each is installed on its own and reported on its own, so a bundle of eight with one corrupt file installs seven and names the one it skipped by its path inside the ZIP. Nothing installed is a plain failure instead -- there is no library to select and no reason to refresh. LibraryInstallResult splits into a single-install arm and a bundle arm to carry that, which is what made the compiler point at the three call sites that had to handle it. Entries the user never put there are skipped: zipping a folder in Finder writes a __MACOSX tree of ._name forks that carry the same extensions and are not libraries, so without this every Mac-authored bundle would report half its entries as corrupt. Entry order is a codepoint comparison rather than localeCompare, whose collation varies with the host's ICU data -- the point of sorting at all is that two machines install a bundle the same way. The ZIP is untrusted. Nothing here writes an entry path to disk, so a traversing path cannot escape anything, but a bomb still costs memory: entry count, per-entry size, total size and compression ratio are checked against declared sizes before a single entry is decompressed. `library install` takes a ZIP too, and reports every outcome. --- docs/CLI.md | 62 +++-- .../__tests__/install-from-zip.test.ts | 242 ++++++++++++++++++ .../__tests__/library-manager-module.test.ts | 3 +- .../library-manager/library-manager-module.ts | 80 +++++- .../__tests__/read-library-bundle.test.ts | 148 +++++++++++ .../shared/library/read-library-bundle.ts | 168 ++++++++++++ src/cli/commands/library.ts | 18 +- src/cli/main.ts | 2 +- .../library-manager/system-libraries-tab.tsx | 33 ++- src/main/modules/ipc/main.ts | 4 +- src/middleware/shared/ports/library-port.ts | 6 +- src/middleware/shared/ports/library-types.ts | 27 +- 12 files changed, 757 insertions(+), 36 deletions(-) create mode 100644 src/backend/editor/library-manager/__tests__/install-from-zip.test.ts create mode 100644 src/backend/shared/library/__tests__/read-library-bundle.test.ts create mode 100644 src/backend/shared/library/read-library-bundle.ts diff --git a/docs/CLI.md b/docs/CLI.md index 2d5617c55..3c0b7fc74 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -224,7 +224,7 @@ version a project compiles against are all scriptable. ```sh openplc-cli library build [--clean] # project -> .stlib -openplc-cli library install +openplc-cli library install openplc-cli library uninstall [@] [--all] openplc-cli library info [@] openplc-cli library list @@ -235,6 +235,30 @@ 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 @@ -341,24 +365,24 @@ 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 | -| `-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 | +| `-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/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 b2e4aeaec..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 @@ -251,7 +251,8 @@ 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(installedPath(librariesDir, 'OSCAT'))).toBe(true) } diff --git a/src/backend/editor/library-manager/library-manager-module.ts b/src/backend/editor/library-manager/library-manager-module.ts index d2758e830..01e21622e 100644 --- a/src/backend/editor/library-manager/library-manager-module.ts +++ b/src/backend/editor/library-manager/library-manager-module.ts @@ -7,8 +7,10 @@ import type { StlibArchiveDTO } from '../../../middleware/shared/ports/library-p 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' @@ -19,6 +21,7 @@ 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' @@ -157,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)}` } @@ -467,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) @@ -477,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')) @@ -487,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) } } @@ -508,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) { 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/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/cli/commands/library.ts b/src/cli/commands/library.ts index e211a73da..62417677f 100644 --- a/src/cli/commands/library.ts +++ b/src/cli/commands/library.ts @@ -241,7 +241,10 @@ function describeVerification(verification: CompileLibraryResult['verification'] async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefined): Promise { if (!stlibPath) { return reporter.failure( - { code: ErrorCode.InvalidArgument, message: 'library install needs the path of a .stlib file.' }, + { + code: ErrorCode.InvalidArgument, + message: 'library install needs the path of a .stlib, .lib, .library or .zip file.', + }, ExitCode.Usage, ) } @@ -252,6 +255,19 @@ async function runLibraryInstall(reporter: Reporter, stlibPath: string | undefin 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}.` }, diff --git a/src/cli/main.ts b/src/cli/main.ts index 8d5881edc..b32a6172e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -81,7 +81,7 @@ Usage openplc-cli install-cli (put openplc-cli on your PATH) openplc-cli devices [--timeout ] openplc-cli library build [--clean] - openplc-cli library install + openplc-cli library install (.stlib, .lib, .library, or a .zip of them) openplc-cli library uninstall [@] [--all] openplc-cli library info [@] openplc-cli library list diff --git a/src/frontend/components/_features/[workspace]/editor/library-manager/system-libraries-tab.tsx b/src/frontend/components/_features/[workspace]/editor/library-manager/system-libraries-tab.tsx index 48c3eea8b..594637245 100644 --- a/src/frontend/components/_features/[workspace]/editor/library-manager/system-libraries-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/library-manager/system-libraries-tab.tsx @@ -61,10 +61,7 @@ const SystemLibrariesTab = ({ installed, onRefresh }: SystemLibrariesTabProps) = if (!library) return setIsPopoverOpen(false) const result = await library.installFromFile() - if (result.success && !result.canceled) { - setSelectedName(result.name) - onRefresh() - } else if (!result.success) { + if (!result.success) { openModal('debugger-message', { type: 'error', title: 'Library install failed', @@ -72,6 +69,34 @@ const SystemLibrariesTab = ({ installed, onRefresh }: SystemLibrariesTabProps) = buttons: ['OK'], onResponse: () => {}, }) + return + } + + // A ZIP installs several at once. Select the first and refresh regardless, + // then name the ones that did not install -- reporting only the failure + // would hide the libraries that are now there. + if ('entries' in result) { + // The module turns "nothing installed" into a plain failure, so there is + // always one here -- read it safely anyway rather than trust an + // invariant the type cannot carry. + const first = result.installed[0] + if (first) setSelectedName(first.name) + onRefresh() + if (result.failed.length > 0) { + openModal('debugger-message', { + type: 'warning', + title: `Installed ${result.installed.length} of ${result.entries.length} libraries`, + message: result.failed.map((entry) => `${entry.path}: ${entry.error}`).join('\n'), + buttons: ['OK'], + onResponse: () => {}, + }) + } + return + } + + if (!result.canceled) { + setSelectedName(result.name) + onRefresh() } }, [library, onRefresh, openModal]) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 8ec898061..78cdca145 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1267,9 +1267,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'], }) diff --git a/src/middleware/shared/ports/library-port.ts b/src/middleware/shared/ports/library-port.ts index 5fe78b5ea..608c946b7 100644 --- a/src/middleware/shared/ports/library-port.ts +++ b/src/middleware/shared/ports/library-port.ts @@ -141,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 diff --git a/src/middleware/shared/ports/library-types.ts b/src/middleware/shared/ports/library-types.ts index 9cbceaa37..ad0c69716 100644 --- a/src/middleware/shared/ports/library-types.ts +++ b/src/middleware/shared/ports/library-types.ts @@ -137,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 @@ -153,6 +156,28 @@ 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 From d01e237d48e795a4e50687c5fc488e12ae6cbd85 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Mon, 14 Sep 2026 17:19:50 +1200 Subject: [PATCH 10/13] Fix Open Recent, and the libraries a project needs going missing Two failures that both left the editor looking like it had simply done nothing. Open Recent crashed the renderer on every use. The main process read the project in handleOpenProjectByPath and sent the service response down project:open-recent-accelerator; the renderer passed that straight to handleOpenProjectResponse. Two things were wrong with it: the response is an envelope of { success, data } and was used as though it were the payload, and the payload it wraps is raw file content that has never been through parseProjectFiles. The store action set meta and data from 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. The channel now carries the path and nothing else. The renderer opens it 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 missing path raises a toast. Across the unsaved-changes modal it holds the path rather than a payload. The second: a project opened with its libraries reported missing, its placed blocks ringed red, and only restarting the editor fixed it. 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 the project that needs it is opened against a pool that predates it. App.tsx re-reads the pool whenever project.meta.path changes, which is already the signal that navigates start -> workspace, so it fires exactly once per open. Re-reading is enough on its own: setSystemLibraries derives the enabled, missing and outdated lists from the project's own refs every time it runs, so the lists are rebuilt against the project that just opened. The CLI's loadProject already hydrates before it opens for the same reason. Covered in library-block-resolution, which drives the real open path: a pool that was stale at open settles once re-read, and a re-read that finds nothing new changes nothing. --- src/App.tsx | 10 +++++ .../_templates/accelerator-handler.tsx | 45 ++++++++++++++----- .../library-block-resolution.test.ts | 34 ++++++++++++++ src/main/menu.ts | 18 ++++++-- .../__tests__/accelerator-adapter.test.ts | 25 ++++++++--- .../adapters/editor/accelerator-adapter.ts | 6 ++- .../shared/ports/accelerator-port.ts | 3 +- 7 files changed, 117 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d8600f120..6c3e56c68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,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(), +) // Register the basedpyright worker URL so the Monaco-side adapter // can spin up the Python LSP on first POU open. No service diff --git a/src/frontend/components/_templates/accelerator-handler.tsx b/src/frontend/components/_templates/accelerator-handler.tsx index 805dc44e8..c409917a2 100644 --- a/src/frontend/components/_templates/accelerator-handler.tsx +++ b/src/frontend/components/_templates/accelerator-handler.tsx @@ -57,7 +57,7 @@ const AcceleratorHandler = () => { } = 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]) @@ -151,28 +151,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) } }, }) @@ -189,7 +210,7 @@ const AcceleratorHandler = () => { } }) return unsub - }, [editingState, accelerator, openModal, handleOpenProjectResponse]) + }, [editingState, accelerator, openModal, openRecentByPath]) /** * Close project diff --git a/src/frontend/store/__tests__/library-block-resolution.test.ts b/src/frontend/store/__tests__/library-block-resolution.test.ts index c6d3840e3..7aedac6b0 100644 --- a/src/frontend/store/__tests__/library-block-resolution.test.ts +++ b/src/frontend/store/__tests__/library-block-resolution.test.ts @@ -316,4 +316,38 @@ describe('a placed library block after project open', () => { 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/main/menu.ts b/src/main/menu.ts index bf42a326d..af4e1d74c 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -111,9 +111,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/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts b/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts index 0f331f97c..4204425fe 100644 --- a/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/accelerator-adapter.test.ts @@ -121,17 +121,30 @@ testAccelerator('onAbout', 'about', 'aboutModalAccelerator') testAccelerator('onQuitApp', 'quitApp', 'quitAppRequest') 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', () => { @@ -139,7 +152,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/accelerator-adapter.ts b/src/middleware/adapters/editor/accelerator-adapter.ts index 713e36ccb..5fb088dce 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/shared/ports/accelerator-port.ts b/src/middleware/shared/ports/accelerator-port.ts index d257d2d39..60ab7e7d2 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. */ From bace4ad388de173b0e60003a6a6ea07f6b4538e0 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Tue, 15 Sep 2026 07:29:32 +1200 Subject: [PATCH 11/13] Stop ringing a block whose instance lives in a global variable list A function-block instance held in a global variable list -- `NET.node`, the pattern the docs use so one node can be shared by a fast task and the application -- painted itself with the red "wrong variable" ring in both ladder and FBD, while the project compiled and ran perfectly. The block elements resolve their instance name against the POU's own `interface.variables`, and a list member is structurally not in that list under any spelling. So the lookup missed and the block called itself wrong. Contacts, coils and variable boxes were always fine on the same names because they validate through `graphical-scope`, which asks the LSP -- and the LSP knows the lists, because `st-lsp/project-sync` reconciles them. `isBlockInstanceInScope` asks that same question for a block instance: resolve the name and compare what comes back to the block's type, case-insensitively as ST is. Both elements now reach for it when the name is qualified and the POU interface has no such variable; an unqualified name that is not in the interface is still wrong on the spot. It answers `undefined` when the LSP cannot -- no worker, or no context yet -- and the caller then leaves the block alone rather than flashing red while the worker warms up, which is the contract `isExpressionValidForType` already keeps for its own `unavailable`. The effects cancel on unmount so a late answer cannot set state on a gone node. --- .../_atoms/graphical-editor/fbd/block.tsx | 16 ++++ .../_atoms/graphical-editor/ladder/block.tsx | 16 ++++ .../graphical-scope-block-instance.test.ts | 81 +++++++++++++++++++ src/frontend/services/graphical-scope.ts | 24 ++++++ 4 files changed, 137 insertions(+) create mode 100644 src/frontend/services/__tests__/graphical-scope-block-instance.test.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index 2ca38f15e..e45c2cfb5 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -2,6 +2,7 @@ import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' import type { PLCVariable } from '../../../../../middleware/shared/ports/types' import { RefreshIcon } from '../../../../assets/icons/interface/Refresh' +import { isBlockInstanceInScope } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { checkVariableName } from '../../../../store/slices/project/validation/variables' import { cn } from '../../../../utils/cn' @@ -465,6 +466,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 } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index d27512df8..5541e4e13 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -4,6 +4,7 @@ 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' @@ -512,6 +513,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 } 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 daa02bfbd..046fe6488 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -157,6 +157,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 From 8bab533268300646a65198bf3df1d417f93d7ec9 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Wed, 16 Sep 2026 11:56:13 +1200 Subject: [PATCH 12/13] Let a placed library block be updated when its library changes A block dropped on a diagram keeps a copy of the pins it had at the time. When the library it came from grows a pin, project load detects that and deliberately does not apply it -- drawing a new pin needs the node's handles rebuilt, and growing every placed block on open would relayout diagrams before the user has seen them. It logs instead, and the only thing that could act on the log was keyed on a version change. A library rebuilt in place, which is what developing one looks like, changes the block without changing its version, so nothing ever fired and the log had no answer. The editors already show an update badge on a block that has drifted, but only ever asked the question of blocks backed by a POU in the project: the check looked the block up in `libraries.user`, and a block from an installed library missed and was skipped. So the badge could not appear on precisely the blocks that come from a library. Detection now falls through to the library pool when the project owns no such POU, in a module both editors share rather than the two copies they each had. It compares the pin SET only -- name and side -- because everything else the re-stamp already applies on load without needing geometry. The version is not consulted: a library that changed is a library that changed. Clicking the badge rebuilds the node from the library through the path the project-POU case already uses, which replaces the node and re-points its edges rather than editing it in place. That replacement is what makes the canvas draw the new pins: it keeps the handles it registered against the old node, so a node edited in place grows its labels and stays unwired. The library POU is translated into the shape that rebuild reads rather than the rebuild being taught about two shapes. The pin-added log line now says which of the two happened. It read "does not draw it yet" whether or not the pin had just been drawn, which is a working update reporting itself as a broken one. Covered by tests for a pin added, removed and moved to the other side, for the implicit EN/ENO/OUT pins and locals that are never drawn --- .../_atoms/graphical-editor/fbd/block.tsx | 43 ++++++- .../_atoms/graphical-editor/ladder/block.tsx | 46 ++++++- .../editor/graphical/FBD/index.tsx | 24 +++- .../editor/graphical/ladder/index.tsx | 24 +++- .../library-block-divergence.test.ts | 116 ++++++++++++++++++ .../utils/PLC/library-block-divergence.ts | 85 +++++++++++++ .../utils/PLC/restamp-library-variants.ts | 13 +- 7 files changed, 332 insertions(+), 19 deletions(-) create mode 100644 src/frontend/utils/PLC/__tests__/library-block-divergence.test.ts create mode 100644 src/frontend/utils/PLC/library-block-divergence.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index e45c2cfb5..ae9fe11bc 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -1,3 +1,4 @@ +import type { SystemLibrary } from '@root/middleware/shared/ports/library-types' import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' import type { PLCVariable } from '../../../../../middleware/shared/ports/types' @@ -13,6 +14,7 @@ import { } from '../../../../utils/graphical/in-out-pin-rules' import { isLegalIdentifier } from '../../../../utils/keywords' import { newUuid } from '../../../../utils/new-uuid' +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 { HighlightedTextArea } from '../../highlighted-textarea' @@ -373,6 +375,39 @@ export const BlockNodeElement = ({ 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() @@ -629,9 +664,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 5541e4e13..777581060 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -1,3 +1,4 @@ +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' @@ -11,6 +12,7 @@ import { checkVariableName } from '../../../../store/slices/project/validation/v 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' @@ -410,6 +412,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 @@ -692,9 +727,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/_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/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/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/restamp-library-variants.ts b/src/frontend/utils/PLC/restamp-library-variants.ts index 4000f8553..a41f8c461 100644 --- a/src/frontend/utils/PLC/restamp-library-variants.ts +++ b/src/frontend/utils/PLC/restamp-library-variants.ts @@ -432,10 +432,15 @@ function lineFor(change: RestampChange, count: number, pous: Set): strin `delete and re-place ${verb(count, 'it', 'them')}.` ) case 'pin-added': - return ( - `${block}: the library added pin ${pin} (${to}). ${plural(count, 'placed block')}${at} ` + - `${verb(count, 'does not draw', 'do not draw')} it yet.` - ) + // 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 ` + From 1449e69641c367e809a14d86534007bd8f1c3f9e Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Thu, 17 Sep 2026 20:13:31 +1200 Subject: [PATCH 13/13] Resolve an array element whose subscript is a variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contact named `bits[i]` drew the red wrong-variable ring while the project compiled, linted and ran. The ring means the editor could not resolve the name, and it was wrong to say so. resolveScopeExpressionType asks the language server for the symbols at an anchor and requires one whose label matches the segment exactly. That is a good design and deliberately so: the server publishes one symbol per IN-BOUNDS array element, which is what makes `bits[99]` flag itself without the editor carrying its own copy of the bounds. A variable subscript has no such symbol and never could, so it fell through to unknown. IEC 61131-3 Ed 3 §8.1.2 shows both spellings on a contact -- `Xs[3]` "as an array element with constant subscript" and `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 restricts a subscript outside ST to "single-element variables or integer literals", and notes that a computed index "can be detected only at runtime", which settles what can be checked here and what cannot. So the variable form is resolved from the element symbols instead. Any `base[...]` symbol carries both the element type and, in its own subscript count, the array's dimensionality -- taken from the symbol rather than by parsing the rendered `ARRAY [0..3] OF BOOL`, so the server stays the authority on both. What can be checked is: the base is an array in scope, the subscript count matches its dimensions, and every variable subscript is ANY_INT. A REAL subscript stays flagged. Bounds are not checked, because they cannot be. An all-literal subscript never reaches the new path, so an out-of-bounds constant still flags exactly as before. The subscript itself resolves against the POU's scope rather than the array's anchor. The `i` in `NET.bits[i]` is a variable of the POU, not a member of `NET`; asking for `NET.i` could only ever miss. A subscript that really is a list member is written out as `NET.bits[NET.idx]` and resolves the same way. splitExpression now cuts at the last dot at bracket depth zero. It cut at the last dot wherever it was, so `NET.bits[NET.idx]` split into the anchor `NET.bits[NET.` and the segment `idx]` -- an anchor that could never resolve and a segment that was not an identifier. This is the rule the language server's own parseChainSegments already documents: dots inside a subscript belong to the index expression, not to the chain. Covered by tests for both subscript forms, each dimension of a multi-dimensional array, a list member's array, a subscript written out as a list member, and the refusals the standard requires -- a REAL subscript, a subscript not in scope, the wrong number of subscripts, a subscript on something that is not an array, and an out-of-bounds constant. --- .../use-st-debug-decorations.test.ts | 207 ++++++++++++++++++ .../graphical-scope-array-element.test.ts | 185 ++++++++++++++++ src/frontend/services/graphical-scope.ts | 83 ++++++- src/frontend/services/st-lsp/scoped-query.ts | 15 +- 4 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 src/frontend/hooks/__tests__/use-st-debug-decorations.test.ts create mode 100644 src/frontend/services/__tests__/graphical-scope-array-element.test.ts 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/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/graphical-scope.ts b/src/frontend/services/graphical-scope.ts index 046fe6488..a8bff6039 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -26,6 +26,7 @@ import { resolveNewVariableType, validateVariableType, } from '../utils/PLC/validate-variable-type' +import type { ScopedCompletionItem } from './st-lsp' import { getScopedQueryApi, isValueCompletionKind, splitExpression } from './st-lsp' /** Max instance/struct variables to drill into when a type-filtered search has no direct hits. */ @@ -136,9 +137,87 @@ 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 } } /** 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) } }