Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "strucpp",
"version": "0.4.21",
"version": "0.4.22",
"description": "IEC 61131-3 Structured Text to C++ Compiler",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
51 changes: 51 additions & 0 deletions src/backend/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@
if (!match) return null;

const areaChar = match[1]!.toUpperCase();
const sizeChar = match[2]?.toUpperCase() || "X";

Check warning on line 99 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const byteIndex = parseInt(match[3]!, 10);
const bitIndex = match[4] ? parseInt(match[4], 10) : 0;

Check warning on line 101 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

const areaMap: Record<string, "Input" | "Output" | "Memory"> = {
I: "Input",
Expand Down Expand Up @@ -587,7 +587,7 @@
// Handle inline array types with dimension info
// Array1D stores T directly — use IECVar-wrapped types for elementary elements
// and bare names for composites (whose fields already contain IECVar leaves)
if (typeRef.arrayDimensions && typeRef.elementTypeName) {

Check warning on line 590 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const elemCpp = this.isUserDefinedType(typeRef.elementTypeName)
? typeRef.elementTypeName
: this.mapVarTypeToCpp(typeRef.elementTypeName);
Expand All @@ -604,7 +604,7 @@
// pointer arithmetic, and pointer-to-integer conversion seamlessly.
// IEC_Ptr needs the raw element type (not IECVar-wrapped).
let elemType: string;
if (typeRef.arrayDimensions && typeRef.elementTypeName) {

Check warning on line 607 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
// Array pointer: baseType is already raw (Array1D<...>)
elemType = baseType;
} else if (this.isUserDefinedType(typeRef.name)) {
Expand Down Expand Up @@ -670,14 +670,14 @@
f.type,
);
// Store array metadata for inline array type reconstruction
if (f.arrayDimensions || f.elementTypeName || f.referenceKind) {

Check warning on line 673 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 673 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const ref: {
arrayDimensions?: Array<{ start: number; end: number }>;
elementTypeName?: string;
referenceKind?: string;
} = {};
if (f.arrayDimensions) ref.arrayDimensions = f.arrayDimensions;
if (f.elementTypeName) ref.elementTypeName = f.elementTypeName;

Check warning on line 680 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
if (f.referenceKind) ref.referenceKind = f.referenceKind;
this.libraryFBFieldTypeRefs.set(
`${fbUpper}.${f.name.toUpperCase()}`,
Expand Down Expand Up @@ -5058,6 +5058,18 @@
const escaped = this.translateIECString(inner);
return `u"${escaped}"`;
}
// Lower IEC numeric literals (based 16#FF/8#17/2#1010, decimals
// with underscore separators, typed prefixes like INT#5, optional
// sign). PROGRAM/GLOBAL VAR initialisers arrive here as raw IEC
// strings; without this they're emitted verbatim (`X(16#FF)`,
// `X(1_000)`, `X(INT#5)`) and the C++ build fails. Mirrors the
// expression-statement path (formatIntegerLiteral). Returns null
// for non-numeric initialisers (enum names, constants), which then
// pass through unchanged.
const numeric = this.lowerNumericInitializer(initialValue);
if (numeric !== null) {
return numeric;
}
return initialValue;
}

Expand Down Expand Up @@ -5101,6 +5113,45 @@
return "";
}

/**
* Lower an IEC numeric literal initializer string to a C++ literal.
*
* Handles based literals (16#FF, 8#17, 2#1010), decimals/reals with
* IEC underscore separators (1_000, 16#FF_FF), an optional leading
* sign (-5, +3), and an optional IEC type prefix (INT#5, BYTE#16#AB,
* REAL#1.5). Reuses {@link iecBaseToCppLiteral}, the same helper the
* expression path uses, so declaration initialisers and statement
* bodies lower identically.
*
* Returns `null` when `raw` is not a recognised numeric literal, so
* non-numeric initialisers (enum names, named constants) pass through
* unchanged at the call site.
*/
private lowerNumericInitializer(raw: string): string | null {
let s = raw.trim();
let sign = "";
if (s.startsWith("-") || s.startsWith("+")) {
sign = s[0]!;
s = s.slice(1).trimStart();
}
// Strip an optional IEC type prefix (TYPE#...). The leading
// identifier must start with a letter/underscore, which excludes
// radix markers like `16#` whose left side is numeric.
const typePrefix = /^[A-Za-z_][A-Za-z0-9_]*#(.+)$/.exec(s);
if (typePrefix) {
s = typePrefix[1]!;
}
const isNumeric =
/^16#[0-9A-Fa-f][0-9A-Fa-f_]*$/.test(s) ||
/^8#[0-7][0-7_]*$/.test(s) ||
/^2#[01][01_]*$/.test(s) ||
/^[0-9][0-9_]*(\.[0-9][0-9_]*)?([eE][+-]?[0-9]+)?$/.test(s);
if (!isNumeric) {
return null;
}
return sign + iecBaseToCppLiteral(s);
}

/**
* Collect all program instances from a configuration.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/node/build-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export function findRuntimeIncludeDir(cxxFlags: string): string | null {
if (typeof import.meta?.url === "string") {
const scriptDir = dirname(new URL(import.meta.url).pathname);
candidates.push(resolve(scriptDir, "runtime", "include"));
// npm/built layout: dist/node/ → ../../src/runtime/include.
// (src/node/ dev layout resolves to the same src/runtime/include.)
candidates.push(
resolve(scriptDir, "..", "..", "src", "runtime", "include"),
);
candidates.push(resolve(scriptDir, "..", "src", "runtime", "include"));
}
} catch {
Expand All @@ -78,6 +83,9 @@ export function findRuntimeIncludeDir(cxxFlags: string): string | null {
// From __dirname (CJS bundle via esbuild)
if (typeof __dirname === "string") {
candidates.push(resolve(__dirname, "runtime", "include"));
candidates.push(
resolve(__dirname, "..", "..", "src", "runtime", "include"),
);
candidates.push(resolve(__dirname, "..", "src", "runtime", "include"));
}

Expand Down
10 changes: 10 additions & 0 deletions src/project-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,16 @@ export class ProjectModelBuilder {
const lit = expr;
return lit.rawValue;
}
if (
expr.kind === "UnaryExpression" &&
(expr.operator === "-" || expr.operator === "+")
) {
// Preserve the sign on numeric literal initialisers (e.g. -5).
// Without this the operand is dropped and the initialiser silently
// falls back to the type's default (0). Codegen lowers the result.
const inner = this.expressionToString(expr.operand);
return inner === "" ? "" : `${expr.operator}${inner}`;
}
if (expr.kind === "VariableExpression") {
if (expr.fieldAccess.length > 0) {
return `${expr.name}.${expr.fieldAccess.join(".")}`;
Expand Down
2 changes: 1 addition & 1 deletion src/version-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
// Copyright (C) 2025 Autonomy / OpenPLC Project
// AUTO-GENERATED by scripts/rebuild-libs.mjs from package.json. Do not edit by hand —
// any changes are overwritten on the next build.
export const STRUCPP_VERSION_BUILD = "0.4.21";
export const STRUCPP_VERSION_BUILD = "0.4.22";
35 changes: 30 additions & 5 deletions tests/backend/build-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect } from "vitest";
import { splitCxxFlags } from "../../dist/cxx-flags.js";
import {
Expand Down Expand Up @@ -53,15 +55,38 @@ describe("findRuntimeIncludeDir", () => {
expect(dir).toContain("include");
});

it("returns null when not found and no -I flags", () => {
// Override CWD to a temp dir where runtime doesn't exist
it("locates the runtime via the package layout when cwd is elsewhere (#134)", () => {
// Regression for #134: on a global/npm install the cwd is the user's
// project, not the package, so the cwd-relative candidate can't help.
// The package-relative candidates (import.meta / __dirname) must still
// resolve the shipped src/runtime/include — the bundle runs from
// dist/node, so it has to climb two levels (../../src/runtime/include),
// not one.
const origCwd = process.cwd;
process.cwd = () => "/tmp";
try {
// This may or may not find it via __dirname / import.meta.url
// Just verify it doesn't throw
const dir = findRuntimeIncludeDir("");
expect(typeof dir === "string" || dir === null).toBe(true);
expect(dir).not.toBeNull();
expect(dir).toContain("runtime");
expect(dir).toContain("include");
// And it must actually contain the canonical runtime header.
expect(existsSync(resolve(dir!, "iec_types.hpp"))).toBe(true);
} finally {
process.cwd = origCwd;
}
});

it("falls back to a -I path from cxx-flags when auto-discovery misses", () => {
const origCwd = process.cwd;
process.cwd = () => "/tmp";
try {
const real = findRuntimeIncludeDir("");
expect(real).not.toBeNull();
// Point auto-discovery at nothing useful, but supply the real dir
// via -I; the flag fallback should recover it.
const viaFlag = findRuntimeIncludeDir(`-I${real}`);
expect(viaFlag).not.toBeNull();
expect(existsSync(resolve(viaFlag!, "iec_types.hpp"))).toBe(true);
} finally {
process.cwd = origCwd;
}
Expand Down
82 changes: 82 additions & 0 deletions tests/backend/codegen-literal-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Companion to codegen-var-initializers.test.ts (issue #133).
*
* The initializer fix lowers numeric literals on the declaration path so
* it matches the statement-body path. These tests pin down the body path
* itself — based literals, IEC underscore separators, typed-literal
* prefixes, signs, and reals must lower to valid C++ wherever a literal
* can appear (assignment RHS, conditions, arithmetic, array indices),
* so the two paths can't drift apart later.
*/

import { describe, it, expect } from "vitest";
import { compile } from "../../dist/index.js";

/** Compile a program body and return just the `run()` method text. */
function runBody(statements: string, vars: string): string {
const result = compile(`
PROGRAM P
VAR ${vars} END_VAR
${statements}
END_PROGRAM
`);
expect(result.success).toBe(true);
const lines = result.cppCode.split("\n");
const start = lines.findIndex((l) => l.includes("void Program_P::run"));
expect(start).toBeGreaterThanOrEqual(0);
const end = lines.findIndex((l, idx) => idx > start && l.trimEnd() === "}");
return lines.slice(start, end + 1).join("\n");
}

describe("issue #133: statement-body literal lowering", () => {
it("lowers based integer/bitstring literals", () => {
const body = runBody("u := 16#FF; u := 8#17; u := 2#1010;", "u : UDINT;");
expect(body).toContain("U = 0xFF;");
expect(body).toContain("U = 017;");
expect(body).toContain("U = 0b1010;");
});

it("strips IEC underscore separators", () => {
const body = runBody("u := 16#FF_FF; u := 1_000;", "u : UDINT;");
expect(body).toContain("U = 0xFFFF;");
expect(body).toContain("U = 1000;");
});

it("lowers typed-literal prefixes to static_cast", () => {
const body = runBody(
"i := INT#5; i := INT#16#10; bt := BYTE#16#AB; wd := WORD#16#1234;",
"i : INT; bt : BYTE; wd : WORD;",
);
expect(body).toContain("static_cast<IEC_INT>(5)");
expect(body).toContain("static_cast<IEC_INT>(0x10)");
expect(body).toContain("static_cast<IEC_BYTE>(0xAB)");
expect(body).toContain("static_cast<IEC_WORD>(0x1234)");
});

it("preserves signs, including on based literals", () => {
const body = runBody("i := -5; i := +3; li := -16#FF;", "i : INT; li : LINT;");
expect(body).toContain("I = -5;");
expect(body).toContain("I = +3;");
expect(body).toContain("LI = -0xFF;");
});

it("lowers based literals inside conditions, arithmetic and indices", () => {
const body = runBody(
"IF u > 16#10 THEN u := u + 16#01; END_IF; arr[2#11] := 16#AA;",
"u : UDINT; arr : ARRAY[0..15] OF INT;",
);
expect(body).toContain("U > 0x10");
expect(body).toContain("U + 0x01");
expect(body).toContain("ARR[0b11] = 0xAA;");
});

it("emits valid reals (decimal point or exponent)", () => {
const body = runBody(
"r := 1.5; r := 1.5E3; lr := 1.5E-10;",
"r : REAL; lr : LREAL;",
);
expect(body).toContain("R = 1.5;");
expect(body).toContain("R = 1500.0;");
expect(body).toContain("LR = 1.5e-10;");
});
});
Loading
Loading