diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb662e6..6c4535f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,6 +285,42 @@ jobs: name: strucpp-win32-x64 path: strucpp-win32-x64.zip + # --------------------------------------------------------------------------- + # npm tarball (platform-independent — used by OpenPLC Editor) + # --------------------------------------------------------------------------- + build-npm: + needs: prepare + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Inject version + run: npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Build + run: npm run build + + - name: Create npm tarball + run: | + npm pack + mv strucpp-*.tgz "strucpp-${VERSION}.tgz" + + - uses: actions/upload-artifact@v4 + with: + name: strucpp-npm + path: strucpp-${{ needs.prepare.outputs.version }}.tgz + # --------------------------------------------------------------------------- # VSCode Extension (.vsix) # --------------------------------------------------------------------------- @@ -331,6 +367,7 @@ jobs: - build-darwin-x64 - build-darwin-arm64 - build-windows-x64 + - build-npm - build-vsix runs-on: ubuntu-latest steps: @@ -350,6 +387,8 @@ jobs: cp artifacts/strucpp-win32-x64/strucpp-win32-x64.zip release/ # Windows ARM64 runs x64 binaries via emulation cp release/strucpp-win32-x64.zip release/strucpp-win32-arm64.zip + # npm tarball (platform-independent, used by OpenPLC Editor) + cp artifacts/strucpp-npm/strucpp-*.tgz release/ cp artifacts/strucpp-vsix/*.vsix release/ ls -lh release/ diff --git a/src/index.ts b/src/index.ts index c4d7d11..731daee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,8 +23,16 @@ import { SymbolTables } from "./semantic/symbol-table.js"; import { SemanticAnalyzer } from "./semantic/analyzer.js"; import { CodeGenerator } from "./backend/codegen.js"; import { StdFunctionRegistry } from "./semantic/std-function-registry.js"; -import type { CompilationUnit } from "./frontend/ast.js"; +import type { + CompilationUnit, + FunctionCallExpression, + FunctionBlockDeclaration, + InterfaceDeclaration, + TypeReference, + ASTNode, +} from "./frontend/ast.js"; import { mergeCompilationUnits } from "./merge.js"; +import { walkAST } from "./ast-utils.js"; import { registerLibrarySymbols, discoverStlibs, @@ -41,6 +49,110 @@ export const defaultOptions: CompileOptions = { optimizationLevel: 0, }; +// --------------------------------------------------------------------------- +// Library tree-shaking: only include libraries whose symbols are referenced +// --------------------------------------------------------------------------- + +/** + * Determine which libraries are actually used by the user program. + * Walks the AST to collect all referenced symbol names, then matches them + * against library manifests to find which libraries are needed. + * Includes transitive dependencies. + */ +function collectUsedLibraries( + ast: CompilationUnit, + archives: StlibArchive[], +): Set { + // Build symbol→library name map (uppercase keys for case-insensitive match) + const symbolToLib = new Map(); + for (const archive of archives) { + const libName = archive.manifest.name; + for (const fn of archive.manifest.functions) { + symbolToLib.set(fn.name.toUpperCase(), libName); + } + for (const fb of archive.manifest.functionBlocks) { + symbolToLib.set(fb.name.toUpperCase(), libName); + } + for (const t of archive.manifest.types) { + symbolToLib.set(t.name.toUpperCase(), libName); + } + } + + // Collect all symbol names referenced in the AST + const referencedNames = new Set(); + + walkAST(ast, (node: ASTNode): void => { + switch (node.kind) { + case "FunctionCallExpression": { + const fc = node as FunctionCallExpression; + referencedNames.add(fc.functionName.toUpperCase()); + break; + } + case "TypeReference": { + const tr = node as TypeReference; + referencedNames.add(tr.name.toUpperCase()); + if (tr.elementTypeName) { + referencedNames.add(tr.elementTypeName.toUpperCase()); + } + break; + } + case "FunctionBlockDeclaration": { + const fbd = node as FunctionBlockDeclaration; + if (fbd.extends) referencedNames.add(fbd.extends.toUpperCase()); + if (fbd.implements) { + for (const iface of fbd.implements) { + referencedNames.add(iface.toUpperCase()); + } + } + break; + } + case "InterfaceDeclaration": { + const id = node as InterfaceDeclaration; + if (id.extends) { + for (const base of id.extends) { + referencedNames.add(base.toUpperCase()); + } + } + break; + } + } + }); + + // Match referenced names to libraries + const usedLibs = new Set(); + for (const name of referencedNames) { + const lib = symbolToLib.get(name); + if (lib) usedLibs.add(lib); + } + + // Add transitive dependencies + const depMap = new Map(); + for (const archive of archives) { + depMap.set( + archive.manifest.name, + archive.dependencies.map((d) => d.name), + ); + } + + let changed = true; + while (changed) { + changed = false; + for (const lib of usedLibs) { + const deps = depMap.get(lib); + if (deps) { + for (const dep of deps) { + if (!usedLibs.has(dep)) { + usedLibs.add(dep); + changed = true; + } + } + } + } + } + + return usedLibs; +} + // --------------------------------------------------------------------------- // Shared pipeline // --------------------------------------------------------------------------- @@ -476,9 +588,21 @@ export function compile( // Register all library metadata (FB types, field mappings, enum/struct types) codegen.registerLibraryArchives(pipeline.allArchives); + + // Tree-shake: only include libraries whose symbols are referenced by the + // program. Skip for test builds — the test harness (test_main.cpp) may + // reference library symbols that aren't in the source AST. + const isTestBuild = pipeline.mergedOptions.isTestBuild ?? false; + const usedLibs = isTestBuild + ? null + : collectUsedLibraries(pipeline.ast, pipeline.allArchives); + // Inject compiled C++ preamble code from libraries for (const archive of pipeline.allArchives) { - if (archive.headerCode) { + if ( + archive.headerCode && + (usedLibs === null || usedLibs.has(archive.manifest.name)) + ) { codegen.addLibraryPreamble( archive.manifest.name, archive.headerCode, diff --git a/tests/integration/oscat-gpp-compile.test.ts b/tests/integration/oscat-gpp-compile.test.ts index 9fccb00..83ae7d7 100644 --- a/tests/integration/oscat-gpp-compile.test.ts +++ b/tests/integration/oscat-gpp-compile.test.ts @@ -75,12 +75,14 @@ describe.skipIf(!hasGpp || !oscatStlibAvailable)( it( "transpiles OSCAT library to C++ via .stlib", () => { - // Compile a minimal dummy program with the OSCAT library loaded + // Compile a minimal dummy program with the OSCAT library loaded. + // isTestBuild disables library tree-shaking so all library code is emitted. const dummyST = "PROGRAM _Dummy\nEND_PROGRAM\n"; const result = compile(dummyST, { headerFileName: "oscat_all.hpp", fileName: "_dummy.st", libraryPaths: [LIBS_DIR], + isTestBuild: true, }); if (!result.success) { @@ -154,6 +156,7 @@ describe.skipIf(!hasGpp || !oscatStlibAvailable)( headerFileName: "oscat_all.hpp", fileName: "_dummy.st", libraryPaths: [LIBS_DIR], + isTestBuild: true, }); const { pous } = dummyResult.ast ? buildPOUInfoFromAST(dummyResult.ast) diff --git a/tests/library/library-system.test.ts b/tests/library/library-system.test.ts index 4b95743..8c50606 100644 --- a/tests/library/library-system.test.ts +++ b/tests/library/library-system.test.ts @@ -1237,4 +1237,222 @@ describe("Library System", () => { expect(result.cppCode).toContain("INLINEFUNC"); }); }); + + describe("conditional library inclusion (tree-shaking)", () => { + it("excludes unused libraries from generated code", () => { + const libResult = compileStlib( + [ + { + source: ` + FUNCTION UnusedFunc : INT + VAR_INPUT x : INT; END_VAR + UnusedFunc := x * 2; + END_FUNCTION + `, + fileName: "unused.st", + }, + ], + { name: "unused-lib", version: "1.0.0", namespace: "unused" }, + ); + expect(libResult.success).toBe(true); + + const source = ` + PROGRAM Main + VAR a : INT; END_VAR + a := 42; + END_PROGRAM + `; + const result = compile(source, { + libraries: [libResult.archive], + }); + + expect(result.success).toBe(true); + expect(result.headerCode).not.toContain("Library: unused-lib"); + expect(result.cppCode).not.toContain("Library: unused-lib"); + }); + + it("includes libraries whose functions are called", () => { + const libResult = compileStlib( + [ + { + source: ` + FUNCTION UsedFunc : INT + VAR_INPUT x : INT; END_VAR + UsedFunc := x + 1; + END_FUNCTION + `, + fileName: "used.st", + }, + ], + { name: "used-lib", version: "1.0.0", namespace: "used" }, + ); + expect(libResult.success).toBe(true); + + const source = ` + PROGRAM Main + VAR a : INT; END_VAR + a := UsedFunc(x := 5); + END_PROGRAM + `; + const result = compile(source, { + libraries: [libResult.archive], + }); + + expect(result.success).toBe(true); + expect(result.headerCode).toContain("Library: used-lib"); + expect(result.cppCode).toContain("Library: used-lib"); + }); + + it("includes libraries whose function blocks are instantiated", () => { + const libResult = compileStlib( + [ + { + source: ` + FUNCTION_BLOCK MyFB + VAR_INPUT x : INT; END_VAR + VAR_OUTPUT y : INT; END_VAR + y := x + 1; + END_FUNCTION_BLOCK + `, + fileName: "myfb.st", + }, + ], + { name: "fb-lib", version: "1.0.0", namespace: "fblib" }, + ); + expect(libResult.success).toBe(true); + + const source = ` + PROGRAM Main + VAR fb : MyFB; END_VAR + fb(x := 10); + END_PROGRAM + `; + const result = compile(source, { + libraries: [libResult.archive], + }); + + expect(result.success).toBe(true); + expect(result.headerCode).toContain("Library: fb-lib"); + expect(result.cppCode).toContain("Library: fb-lib"); + }); + + it("includes libraries whose types are used", () => { + const libResult = compileStlib( + [ + { + source: ` + TYPE + MyStruct : STRUCT + a : INT; + b : INT; + END_STRUCT; + END_TYPE + FUNCTION DummyFn : INT + VAR_INPUT x : INT; END_VAR + DummyFn := x; + END_FUNCTION + `, + fileName: "types.st", + }, + ], + { name: "type-lib", version: "1.0.0", namespace: "typelib" }, + ); + expect(libResult.success).toBe(true); + + const source = ` + PROGRAM Main + VAR s : MyStruct; END_VAR + s.a := 1; + END_PROGRAM + `; + const result = compile(source, { + libraries: [libResult.archive], + }); + + expect(result.success).toBe(true); + expect(result.headerCode).toContain("Library: type-lib"); + }); + + it("excludes one library but includes another when only one is used", () => { + const usedLib = compileStlib( + [ + { + source: ` + FUNCTION Alpha : INT + VAR_INPUT x : INT; END_VAR + Alpha := x; + END_FUNCTION + `, + fileName: "alpha.st", + }, + ], + { name: "alpha-lib", version: "1.0.0", namespace: "alpha" }, + ); + const unusedLib = compileStlib( + [ + { + source: ` + FUNCTION Beta : INT + VAR_INPUT x : INT; END_VAR + Beta := x; + END_FUNCTION + `, + fileName: "beta.st", + }, + ], + { name: "beta-lib", version: "1.0.0", namespace: "beta" }, + ); + expect(usedLib.success).toBe(true); + expect(unusedLib.success).toBe(true); + + const source = ` + PROGRAM Main + VAR a : INT; END_VAR + a := Alpha(x := 1); + END_PROGRAM + `; + const result = compile(source, { + libraries: [usedLib.archive, unusedLib.archive], + }); + + expect(result.success).toBe(true); + expect(result.headerCode).toContain("Library: alpha-lib"); + expect(result.headerCode).not.toContain("Library: beta-lib"); + expect(result.cppCode).toContain("Library: alpha-lib"); + expect(result.cppCode).not.toContain("Library: beta-lib"); + }); + + it("includes all libraries when isTestBuild is true", () => { + const libResult = compileStlib( + [ + { + source: ` + FUNCTION UnreferencedFunc : INT + VAR_INPUT x : INT; END_VAR + UnreferencedFunc := x; + END_FUNCTION + `, + fileName: "unreferenced.st", + }, + ], + { name: "unreferenced-lib", version: "1.0.0", namespace: "unref" }, + ); + expect(libResult.success).toBe(true); + + const source = ` + PROGRAM Main + VAR a : INT; END_VAR + a := 1; + END_PROGRAM + `; + const result = compile(source, { + libraries: [libResult.archive], + isTestBuild: true, + }); + + expect(result.success).toBe(true); + expect(result.headerCode).toContain("Library: unreferenced-lib"); + expect(result.cppCode).toContain("Library: unreferenced-lib"); + }); + }); });