From 3f69cc19c7106b694a504c7019069eaaf9cb1881 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Apr 2026 13:25:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20tree-shake=20library=20preambles=20?= =?UTF-8?q?=E2=80=94=20only=20include=20libraries=20whose=20symbols=20are?= =?UTF-8?q?=20used?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the AST after semantic analysis to collect all referenced symbol names (function calls, type references, FB instantiations, inheritance) and matches them against library manifests. Only libraries with at least one referenced symbol (plus transitive dependencies) are injected into the generated C++ output. Test builds (isTestBuild) skip tree-shaking since the test harness may reference any library symbol. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 128 +++++++++++- tests/integration/oscat-gpp-compile.test.ts | 5 +- tests/library/library-system.test.ts | 218 ++++++++++++++++++++ 3 files changed, 348 insertions(+), 3 deletions(-) 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"); + }); + }); }); From cb99d9e844f21b5adaa4a4c0a4d4f876bac90dc2 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Apr 2026 13:30:31 -0400 Subject: [PATCH 2/2] ci: add npm tarball artifact to release workflow Add a new build-npm job that runs `npm pack` to produce a platform-independent .tgz tarball. This artifact is uploaded to GitHub Releases alongside the existing platform-specific binaries. The npm tarball is used by OpenPLC Editor to install STruC++ as a TypeScript dependency (for the compile() API) while also bundling the C++ runtime headers and .stlib libraries in a single versioned artifact. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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/