nodalis-compiler 1.0.30 → 1.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.31] 2026-04-28
4
+ - Added an "exports.json" file that can be used to designate exported local variables in
5
+ programs when compiling a folder of ST files.
6
+
3
7
  ## [1.0.30] 2026-03-30
4
8
  - Fixed issue with boolean assignments in C++
5
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodalis-compiler",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Compiles IEC-61131-3/10 languages into code that can be used as a PLC on multiple platforms.",
5
5
  "icon": "nodalis.png",
6
6
  "main": "src/nodalis.js",
@@ -45,6 +45,163 @@ export class CPPCompiler extends Compiler {
45
45
  this.name = 'CPPCompiler';
46
46
  }
47
47
 
48
+ loadStructuredTextBundle(sourcePath, resourceName) {
49
+ const exports = this.loadStructuredTextBundleExports(sourcePath, resourceName);
50
+ const stFiles = this.listStructuredTextBundleFiles(sourcePath);
51
+
52
+ if (stFiles.length === 0) {
53
+ throw new Error(`No .st files found in source directory "${sourcePath}".`);
54
+ }
55
+
56
+ const normalizedResource = String(resourceName || '').trim();
57
+ const candidateNames = new Set([
58
+ normalizedResource,
59
+ normalizedResource.toLowerCase(),
60
+ normalizedResource.toLowerCase().endsWith('.st') ? normalizedResource.toLowerCase() : `${normalizedResource.toLowerCase()}.st`
61
+ ]);
62
+
63
+ const entryFile = stFiles.find((file) => {
64
+ const lower = file.toLowerCase();
65
+ return candidateNames.has(file) || candidateNames.has(lower);
66
+ });
67
+
68
+ if (!entryFile) {
69
+ throw new Error(`resourceName "${resourceName}" is not an .st file in "${sourcePath}".`);
70
+ }
71
+
72
+ const orderedFiles = [
73
+ ...stFiles.filter((file) => file !== entryFile).sort((a, b) => a.localeCompare(b)),
74
+ entryFile
75
+ ];
76
+
77
+ let combinedSource = this.buildStructuredTextExportGlobals(exports);
78
+ let currentLine = combinedSource.length > 0 ? combinedSource.split('\n').length + 2 : 1;
79
+ const fileLineMappings = [];
80
+
81
+ for (const file of orderedFiles) {
82
+ const originalSource = fs.readFileSync(path.join(sourcePath, file), 'utf-8');
83
+ const fileSource = (file === entryFile
84
+ ? this.addStructuredTextExportAssignments(originalSource, exports)
85
+ : originalSource).trim();
86
+ if (fileSource.length === 0) continue;
87
+
88
+ if (combinedSource.length > 0) {
89
+ combinedSource += '\n\n';
90
+ }
91
+
92
+ const lineCount = fileSource.split('\n').length;
93
+ fileLineMappings.push({
94
+ file,
95
+ startLine: currentLine,
96
+ endLine: currentLine + lineCount - 1
97
+ });
98
+ combinedSource += fileSource;
99
+ currentLine += lineCount + 1;
100
+ }
101
+
102
+ const entrySource = fs.readFileSync(path.join(sourcePath, entryFile), 'utf-8');
103
+ const entryProgramName = this.extractFirstProgramName(entrySource) || path.basename(entryFile, path.extname(entryFile));
104
+
105
+ return { combinedSource, entryProgramName, fileLineMappings };
106
+ }
107
+
108
+ loadStructuredTextBundleExports(sourcePath, resourceName) {
109
+ const exportsPath = path.join(sourcePath, 'exports.json');
110
+ const resourceKey = this.getStructuredTextExportResourceKey(resourceName);
111
+ const defaultExports = {
112
+ T_MAIN: [{ global: 'Result', type: 'BOOL', address: '%MX0.0', variable: 'bResult' }]
113
+ };
114
+
115
+ if (!fs.existsSync(exportsPath)) {
116
+ fs.writeFileSync(exportsPath, JSON.stringify(defaultExports, null, 4));
117
+ return resourceKey === this.getStructuredTextExportResourceKey('T_MAIN') ? defaultExports.T_MAIN : [];
118
+ }
119
+
120
+ let exportConfig;
121
+ try {
122
+ exportConfig = JSON.parse(fs.readFileSync(exportsPath, 'utf-8'));
123
+ } catch (err) {
124
+ throw new Error(`Failed to load exports.json from ${exportsPath}: ${err.message}`);
125
+ }
126
+
127
+ if (typeof exportConfig !== 'object' || exportConfig === null || Array.isArray(exportConfig)) {
128
+ throw new Error(`exports.json in ${sourcePath} must be an object keyed by resource name.`);
129
+ }
130
+
131
+ const matchedKey = Object.keys(exportConfig).find((key) => this.getStructuredTextExportResourceKey(key) === resourceKey);
132
+ if (!matchedKey) return [];
133
+
134
+ const exports = exportConfig[matchedKey];
135
+ if (!Array.isArray(exports)) {
136
+ throw new Error(`exports.json entry for resource "${matchedKey}" must be an array.`);
137
+ }
138
+ return exports.map((entry, index) => this.validateStructuredTextBundleExport(entry, index, exportsPath));
139
+ }
140
+
141
+ getStructuredTextExportResourceKey(resourceName) {
142
+ return this.getStructuredTextExportResourceName(resourceName).toLowerCase();
143
+ }
144
+
145
+ getStructuredTextExportResourceName(resourceName) {
146
+ const normalized = String(resourceName || '').trim();
147
+ return path.basename(normalized, path.extname(normalized));
148
+ }
149
+
150
+ validateStructuredTextBundleExport(entry, index, exportsPath) {
151
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
152
+ throw new Error(`Invalid export at ${exportsPath}[${index}]: expected an object.`);
153
+ }
154
+
155
+ const global = String(entry.global || '').trim();
156
+ const type = String(entry.type || 'BOOL').trim();
157
+ const address = String(entry.address || '').trim();
158
+ const variable = String(entry.variable || '').trim();
159
+ const identifierPattern = /^[A-Za-z_]\w*$/;
160
+ const variablePattern = /^[A-Za-z_]\w*(?:\.\w+)?$/;
161
+ const addressPattern = /^%[IQM][A-Z]*\d+(?:\.\d+)?$/i;
162
+
163
+ if (!identifierPattern.test(global)) {
164
+ throw new Error(`Invalid export global at ${exportsPath}[${index}]: "${entry.global}" is not a valid ST identifier.`);
165
+ }
166
+ if (!identifierPattern.test(type)) {
167
+ throw new Error(`Invalid export type at ${exportsPath}[${index}]: "${entry.type}" is not a valid ST type identifier.`);
168
+ }
169
+ if (!addressPattern.test(address)) {
170
+ throw new Error(`Invalid export address at ${exportsPath}[${index}]: "${entry.address}" is not a valid ST address.`);
171
+ }
172
+ if (!variablePattern.test(variable)) {
173
+ throw new Error(`Invalid export variable at ${exportsPath}[${index}]: "${entry.variable}" is not a valid ST variable reference.`);
174
+ }
175
+
176
+ return { global, type, address, variable };
177
+ }
178
+
179
+ buildStructuredTextExportGlobals(exports) {
180
+ if (!Array.isArray(exports) || exports.length === 0) return '';
181
+
182
+ return [
183
+ 'VAR_GLOBAL',
184
+ ...exports.flatMap((entry) => [
185
+ ` ${entry.global} AT ${entry.address} : ${entry.type};`,
186
+ `//Global=${JSON.stringify({ Name: entry.global, Address: entry.address })}`
187
+ ]),
188
+ 'END_VAR'
189
+ ].join('\n');
190
+ }
191
+
192
+ addStructuredTextExportAssignments(entrySource, exports) {
193
+ if (!Array.isArray(exports) || exports.length === 0) return entrySource;
194
+
195
+ const assignmentBlock = exports.map((entry) => `${entry.global} := ${entry.variable};`).join('\n');
196
+ const endProgramPattern = /^(\s*END_PROGRAM\b[^\r\n]*)/im;
197
+
198
+ if (!endProgramPattern.test(entrySource)) {
199
+ throw new Error('Entry program does not contain END_PROGRAM; cannot add exports.json assignments.');
200
+ }
201
+
202
+ return entrySource.replace(endProgramPattern, `${assignmentBlock}\n$1`);
203
+ }
204
+
48
205
  get supportedLanguages() {
49
206
  return [IECLanguage.STRUCTURED_TEXT, IECLanguage.LADDER_DIAGRAM];
50
207
  }
@@ -301,18 +458,22 @@ int main() {
301
458
 
302
459
  inputs.push(`"${open62541o}"`);
303
460
  inputs.push(`"${bacneta}"`);
461
+ const debugFlags = this.getDebugCompileFlags(compiler);
304
462
 
305
463
  if (compiler === 'cl.exe') {
306
464
  const cppFlagSegment = formatFlags(archFlags.cpp);
307
- cppCompileCmd = `cl.exe /I${bacneti} /I${bacneti}/ports/${isWindowsTarget ? "win32" : "linux"} ${cppFlagSegment}/EHsc /std:c++17 /Fe:"${exeFile}" ` +
465
+ cppCompileCmd = `cl.exe /I${bacneti} /I${bacneti}/ports/${isWindowsTarget ? "win32" : "linux"} ${cppFlagSegment}${debugFlags} /EHsc /std:c++17 /Fe:"${exeFile}" ` +
308
466
  `"${cppFile}" "${pathTo('nodalis.cpp')}" "${pathTo('modbus.cpp')}" "${pathTo('opcua.cpp')}" "${pathTo('bacnet.cpp')}" "${pathTo('gpio.cpp')}"`; //"${pathTo('open62541.obj')}"`;
309
467
  } else {
310
468
  const cppFlagSegment = formatFlags(archFlags.cpp);
311
- cppCompileCmd = `${compiler} ${cppFlagSegment}-std=c++17 -I${bacneti} -I${bacneti}/ports/${isWindowsTarget ? "win32" : "linux"} -o "${exeFile}" ${inputs.join(' ')} ${archFlags.linker}`;
469
+ cppCompileCmd = `${compiler} ${cppFlagSegment}${debugFlags} -std=c++17 -I${bacneti} -I${bacneti}/ports/${isWindowsTarget ? "win32" : "linux"} -o "${exeFile}" ${inputs.join(' ')} ${archFlags.linker}`;
312
470
  }
313
471
 
314
472
  try {
315
473
  execSync(cppCompileCmd, { stdio: 'pipe' });
474
+ if (targetInfo.os === 'macos') {
475
+ this.generateMacOSDebugSymbols(exeFile);
476
+ }
316
477
  if (isWindowsTarget) {
317
478
  this.copyWindowsRuntimeDependencies(compiler, binDir);
318
479
  }
@@ -326,6 +487,19 @@ int main() {
326
487
  }
327
488
  }
328
489
 
490
+ getDebugCompileFlags(compiler) {
491
+ if (compiler === 'cl.exe') return '/Zi /Od';
492
+ return '-g -O0';
493
+ }
494
+
495
+ generateMacOSDebugSymbols(exeFile) {
496
+ try {
497
+ execSync(`dsymutil "${exeFile}"`, { stdio: 'pipe' });
498
+ } catch {
499
+ // Debug symbols are best-effort; the executable is still usable without a dSYM.
500
+ }
501
+ }
502
+
329
503
  copyWindowsRuntimeDependencies(compiler, binDir) {
330
504
  const compilerName = String(compiler || '').toLowerCase();
331
505
  const isMingwToolchain = compilerName.includes('mingw') || compilerName.includes('w64');
@@ -102,6 +102,7 @@ export function convertExpression(expr, isjsfb = false, jsfbVars = [], isjs=fals
102
102
  .replace(/\b(?<![><!])=(?!=)/g, '=='); // ✅ fix assignment/comparison
103
103
 
104
104
  results = rewriteExponentOperator(results, isjs);
105
+ results = rewriteNumericNotOperator(results);
105
106
 
106
107
  // JS accepts 0o..., but C++ requires legacy octal form 0...
107
108
  if (!isjs) {
@@ -150,6 +151,13 @@ export function convertExpression(expr, isjsfb = false, jsfbVars = [], isjs=fals
150
151
  return results;
151
152
  }
152
153
 
154
+ function rewriteNumericNotOperator(expression) {
155
+ return String(expression || '').replace(
156
+ /!\s+([+-]?(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+))/g,
157
+ '~$1'
158
+ );
159
+ }
160
+
153
161
  function rewriteExponentOperator(expression, isjs) {
154
162
  let result = String(expression || '');
155
163
  const operand = String.raw`(?:\([^()]*\)|[A-Za-z_]\w*(?:\.\d+)?|[+-]?(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|(?:\d+\.\d*|\d*\.\d+|\d+)(?:[eE][+-]?\d+)?))`;