lasal-mcp 0.1.0

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/core/envelope.js +8 -0
  4. package/dist/core/errors.js +12 -0
  5. package/dist/core/http.js +19 -0
  6. package/dist/core/process.js +30 -0
  7. package/dist/core/response.js +37 -0
  8. package/dist/core/scratch.js +6 -0
  9. package/dist/core/staticServer.js +88 -0
  10. package/dist/server.js +230 -0
  11. package/dist/state.js +57 -0
  12. package/dist/tools/applyProjectChanges.js +371 -0
  13. package/dist/tools/deployAll.js +320 -0
  14. package/dist/tools/hmiBrowser.js +224 -0
  15. package/dist/tools/hmiRuntime.js +273 -0
  16. package/dist/tools/inspectProject.js +162 -0
  17. package/dist/tools/inspectVisuProject.js +474 -0
  18. package/dist/tools/larsRuntime.js +536 -0
  19. package/dist/tools/lasalApps.js +111 -0
  20. package/dist/tools/plcControl.js +530 -0
  21. package/dist/tools/plcDiagnostics.js +172 -0
  22. package/dist/tools/readClassSource.js +147 -0
  23. package/dist/tools/selectProject.js +47 -0
  24. package/dist/tools/setTargetIp.js +114 -0
  25. package/dist/tools/status.js +146 -0
  26. package/dist/tools/visuControl.js +447 -0
  27. package/dist/tools/visuDashboard.js +571 -0
  28. package/dist/utils/batchScript.js +257 -0
  29. package/dist/utils/config.js +34 -0
  30. package/dist/utils/editTransaction.js +39 -0
  31. package/dist/utils/engine.js +163 -0
  32. package/dist/utils/lars.js +471 -0
  33. package/dist/utils/lasalXml.js +758 -0
  34. package/dist/utils/preflight.js +194 -0
  35. package/dist/utils/projectScanner.js +212 -0
  36. package/dist/utils/resolvePaths.js +46 -0
  37. package/dist/utils/respond.js +14 -0
  38. package/dist/utils/scriptRunner.js +161 -0
  39. package/dist/utils/visuDashboardIO.js +198 -0
  40. package/dist/utils/visuPropertyEncoding.js +174 -0
  41. package/dist/utils/visuScript.js +262 -0
  42. package/package.json +64 -0
@@ -0,0 +1,758 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { resolve, dirname, basename } from "path";
3
+ import { XMLParser } from "fast-xml-parser";
4
+ import { randomUUID } from "crypto";
5
+ // ─── ISO-8859-1 I/O ─────────────────────────────────────────────────────────
6
+ export function readLatin1(path) {
7
+ return readFileSync(path, "latin1");
8
+ }
9
+ export function writeLatin1(path, content) {
10
+ writeFileSync(path, content, "latin1");
11
+ }
12
+ // ─── XML Parser (read-only) ──────────────────────────────────────────────────
13
+ // Tags that can appear multiple times as siblings
14
+ const ALWAYS_ARRAY = new Set(["File", "Server", "Client", "Object", "Connection", "RemoteObject", "Folder"]);
15
+ const parser = new XMLParser({
16
+ ignoreAttributes: false,
17
+ attributeNamePrefix: "@_",
18
+ isArray: (name, jpath) => {
19
+ if (ALWAYS_ARRAY.has(name))
20
+ return true;
21
+ const path = String(jpath);
22
+ // <Network> inside <Networks> (sub-networks of objects) can be multiple
23
+ if (name === "Network" && path.endsWith(".Networks.Network"))
24
+ return true;
25
+ // <Class> inside SigmatekFolders
26
+ if (name === "Class" && path.includes("SigmatekFolders"))
27
+ return true;
28
+ return false;
29
+ },
30
+ parseAttributeValue: false, // keep everything as strings
31
+ });
32
+ // ─── GUID helper ─────────────────────────────────────────────────────────────
33
+ export function newGuid() {
34
+ return `{${randomUUID().toUpperCase()}}`;
35
+ }
36
+ export function parseLcp(lcpPath) {
37
+ const raw = readLatin1(lcpPath);
38
+ const doc = parser.parse(raw);
39
+ const proj = doc.Project ?? {};
40
+ const projectDir = dirname(lcpPath);
41
+ function abs(rel) {
42
+ return resolve(projectDir, rel.replace(/\\/g, "/"));
43
+ }
44
+ const classFiles = [];
45
+ for (const f of (proj.ClassFiles?.File ?? [])) {
46
+ if (f?.["@_Path"])
47
+ classFiles.push({ relativePath: f["@_Path"], absPath: abs(f["@_Path"]) });
48
+ }
49
+ const networkFiles = [];
50
+ for (const f of (proj.NetworkFiles?.File ?? [])) {
51
+ if (f?.["@_Path"])
52
+ networkFiles.push({ relativePath: f["@_Path"], absPath: abs(f["@_Path"]) });
53
+ }
54
+ const dirs = proj.Options?.Directories ?? {};
55
+ const classDir = abs(dirs["@_Class"] ?? ".\\Class\\");
56
+ const networkDir = abs(dirs["@_Network"] ?? ".\\Network\\");
57
+ return {
58
+ projectName: proj["@_Name"] ?? basename(lcpPath, ".lcp"),
59
+ lcpPath,
60
+ projectDir,
61
+ classFiles,
62
+ networkFiles,
63
+ classDir,
64
+ networkDir,
65
+ };
66
+ }
67
+ function extractStBlock(content) {
68
+ const si = content.indexOf("(*!");
69
+ if (si < 0)
70
+ throw new Error("No (*! declaration block found in .st file");
71
+ // Scan forward from after "(*!" tracking nested (* *) pairs to find the
72
+ // matching *) for this opener. This prevents a plain (* comment *) earlier
73
+ // in the XML payload or a *) inside a string from breaking extraction.
74
+ let depth = 1;
75
+ let pos = si + 3;
76
+ while (pos < content.length - 1) {
77
+ if (content[pos] === "(" && content[pos + 1] === "*") {
78
+ depth++;
79
+ pos += 2;
80
+ }
81
+ else if (content[pos] === "*" && content[pos + 1] === ")") {
82
+ depth--;
83
+ if (depth === 0) {
84
+ const xml = content.slice(si + 3, pos);
85
+ // Validate: the extracted content must contain at least one XML tag
86
+ if (!/<\w/.test(xml)) {
87
+ throw new Error("Extracted (*! ... *) block does not contain valid XML");
88
+ }
89
+ return {
90
+ pre: content.slice(0, si + 3),
91
+ xml,
92
+ post: content.slice(pos),
93
+ };
94
+ }
95
+ pos += 2;
96
+ }
97
+ else {
98
+ pos++;
99
+ }
100
+ }
101
+ throw new Error("No closing *) found for (*! block in .st file");
102
+ }
103
+ export function parseStClass(stPath) {
104
+ const raw = readLatin1(stPath);
105
+ const { xml } = extractStBlock(raw);
106
+ const doc = parser.parse(xml);
107
+ // Class is the root element (single, not array)
108
+ const cls = doc.Class ?? {};
109
+ // Channels is a single element containing Server[] and Client[]
110
+ const channels = cls.Channels ?? {};
111
+ const servers = [];
112
+ for (const s of toArray(channels.Server)) {
113
+ servers.push({
114
+ name: s["@_Name"] ?? "",
115
+ guid: s["@_GUID"] ?? newGuid(),
116
+ visualized: s["@_Visualized"] === "true",
117
+ initialize: s["@_Initialize"] === "true",
118
+ defValue: s["@_DefValue"],
119
+ writeProtected: s["@_WriteProtected"] === "true",
120
+ retentive: s["@_Retentive"] ?? "false",
121
+ comment: s["@_Comment"],
122
+ });
123
+ }
124
+ const clients = [];
125
+ for (const c of toArray(channels.Client)) {
126
+ clients.push({
127
+ name: c["@_Name"] ?? "",
128
+ required: c["@_Required"] === "true",
129
+ internal: c["@_Internal"] === "true",
130
+ comment: c["@_Comment"],
131
+ });
132
+ }
133
+ return {
134
+ name: cls["@_Name"] ?? basename(stPath, ".st"),
135
+ revision: cls["@_Revision"],
136
+ guid: cls["@_GUID"],
137
+ cyclicTask: cls["@_CyclicTask"] === "true",
138
+ realtimeTask: cls["@_RealtimeTask"] === "true",
139
+ backgroundTask: cls["@_BackgroundTask"] === "true",
140
+ servers,
141
+ clients,
142
+ };
143
+ }
144
+ function toArray(v) {
145
+ if (v === undefined || v === null)
146
+ return [];
147
+ return Array.isArray(v) ? v : [v];
148
+ }
149
+ function collectObjects(components) {
150
+ const result = [];
151
+ for (const obj of toArray(components)) {
152
+ const channels = obj.Channels;
153
+ const channelValues = {};
154
+ for (const sv of toArray(channels?.Server)) {
155
+ if (sv["@_Value"])
156
+ channelValues[sv["@_Name"]] = sv["@_Value"];
157
+ }
158
+ for (const cl of toArray(channels?.Client)) {
159
+ if (cl["@_Value"])
160
+ channelValues[cl["@_Name"]] = cl["@_Value"];
161
+ }
162
+ result.push({
163
+ name: obj["@_Name"] ?? "",
164
+ guid: obj["@_GUID"],
165
+ className: obj["@_Class"] ?? "",
166
+ position: obj["@_Position"],
167
+ channelValues,
168
+ });
169
+ // recurse into sub-networks (Network may be array or single)
170
+ for (const subNet of toArray(obj.Networks?.Network)) {
171
+ result.push(...collectObjects(toArray(subNet.Components?.Object)));
172
+ }
173
+ }
174
+ return result;
175
+ }
176
+ function collectConnections(network) {
177
+ const result = [];
178
+ for (const conn of toArray(network.Connections?.Connection)) {
179
+ result.push({
180
+ source: conn["@_Source"] ?? "",
181
+ destination: conn["@_Destination"] ?? "",
182
+ remote: !!conn["@_Station"],
183
+ station: conn["@_Station"],
184
+ });
185
+ }
186
+ // recurse into sub-networks inside objects
187
+ for (const obj of toArray(network.Components?.Object)) {
188
+ for (const subNet of toArray(obj.Networks?.Network)) {
189
+ result.push(...collectConnections(subNet));
190
+ }
191
+ }
192
+ return result;
193
+ }
194
+ export function parseLcn(lcnPath) {
195
+ const raw = readLatin1(lcnPath);
196
+ const doc = parser.parse(raw);
197
+ // Network is the root element (single, not array)
198
+ const net = doc.Network ?? {};
199
+ return {
200
+ name: net["@_Name"] ?? basename(lcnPath, ".lcn"),
201
+ lcnPath,
202
+ objects: collectObjects(toArray(net.Components?.Object)),
203
+ connections: collectConnections(net),
204
+ };
205
+ }
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+ // .st string editing (targeted, preserves formatting + encoding)
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ function editStBlock(stPath, editFn) {
210
+ const raw = readLatin1(stPath);
211
+ const { pre, xml, post } = extractStBlock(raw);
212
+ const newXml = editFn(xml);
213
+ writeLatin1(stPath, pre + newXml + post);
214
+ }
215
+ function serverLineRegex(name) {
216
+ return new RegExp(`[ \\t]*<Server\\s+[^>]*Name\\s*=\\s*"${escapeRe(name)}"[^>]*\\/>\\s*\\r?\\n?`, "s");
217
+ }
218
+ function clientLineRegex(name) {
219
+ return new RegExp(`[ \\t]*<Client\\s+[^>]*Name\\s*=\\s*"${escapeRe(name)}"[^>]*\\/>\\s*\\r?\\n?`, "s");
220
+ }
221
+ function escapeRe(s) {
222
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
223
+ }
224
+ function serverXmlLine(s, indent, eol = "\n") {
225
+ let line = `${indent}<Server Name="${s.name}" GUID="${s.guid}" Visualized="${s.visualized}" Initialize="${s.initialize}"`;
226
+ if (s.defValue !== undefined)
227
+ line += ` DefValue="${s.defValue}"`;
228
+ line += ` WriteProtected="${s.writeProtected}" Retentive="${s.retentive}"`;
229
+ if (s.comment)
230
+ line += ` Comment="${s.comment}"`;
231
+ line += "/>" + eol;
232
+ return line;
233
+ }
234
+ function clientXmlLine(c, indent, eol = "\n") {
235
+ let line = `${indent}<Client Name="${c.name}" Required="${c.required}" Internal="${c.internal}"`;
236
+ if (c.comment)
237
+ line += ` Comment="${c.comment}"`;
238
+ line += "/>" + eol;
239
+ return line;
240
+ }
241
+ function detectChannelsIndent(xml) {
242
+ const m = xml.match(/(\t+)<(?:Server|Client)\s+Name/);
243
+ return m?.[1] ?? "\t\t";
244
+ }
245
+ export function addServerToSt(stPath, server) {
246
+ const ch = { ...server, guid: server.guid ?? newGuid() };
247
+ editStBlock(stPath, (xml) => {
248
+ const indent = detectChannelsIndent(xml);
249
+ const eol = xml.includes("\r\n") ? "\r\n" : "\n";
250
+ const line = serverXmlLine(ch, indent, eol);
251
+ // Replace `(closingIndent)</Channels>` preserving the closing tag's own indent
252
+ return xml.replace(/([ \t]*)<\/Channels>/, (_, closingIndent) => line + closingIndent + "</Channels>");
253
+ });
254
+ }
255
+ export function removeServerFromSt(stPath, name) {
256
+ editStBlock(stPath, (xml) => xml.replace(serverLineRegex(name), ""));
257
+ }
258
+ export function renameServerInSt(stPath, oldName, newName) {
259
+ editStBlock(stPath, (xml) => {
260
+ const re = new RegExp(`(<Server\\s+Name\\s*=\\s*")${escapeRe(oldName)}"`, "");
261
+ return xml.replace(re, `$1${newName}"`);
262
+ });
263
+ }
264
+ export function addClientToSt(stPath, client) {
265
+ editStBlock(stPath, (xml) => {
266
+ const indent = detectChannelsIndent(xml);
267
+ const eol = xml.includes("\r\n") ? "\r\n" : "\n";
268
+ const line = clientXmlLine(client, indent, eol);
269
+ return xml.replace(/([ \t]*)<\/Channels>/, (_, closingIndent) => line + closingIndent + "</Channels>");
270
+ });
271
+ }
272
+ export function removeClientFromSt(stPath, name) {
273
+ editStBlock(stPath, (xml) => xml.replace(clientLineRegex(name), ""));
274
+ }
275
+ export function renameClientInSt(stPath, oldName, newName) {
276
+ editStBlock(stPath, (xml) => {
277
+ const re = new RegExp(`(<Client\\s+Name\\s*=\\s*")${escapeRe(oldName)}"`, "");
278
+ return xml.replace(re, `$1${newName}"`);
279
+ });
280
+ }
281
+ // ─────────────────────────────────────────────────────────────────────────────
282
+ // .lcn cascade edits (rename/remove channel references + connections)
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+ // Applies editFn to each <Object ... Class="className" ...> block in the lcn content.
285
+ // editFn receives the inner content between <Object ...> and </Object> and returns it modified.
286
+ function editObjectsOfClass(content, className, editFn) {
287
+ // We match the Class attribute value in the opening <Object tag
288
+ // then capture up to the matching </Object>
289
+ // Strategy: find each occurrence, replace in-place
290
+ let result = content;
291
+ let searchFrom = 0;
292
+ while (true) {
293
+ // Find the next <Object...> tag with this class
294
+ const objTagRe = new RegExp(`<Object(?=[^>]*\\bClass\\s*=\\s*"${escapeRe(className)}"[^>]*)([^>]*)>`, "s");
295
+ const searchStr = result.slice(searchFrom);
296
+ const match = objTagRe.exec(searchStr);
297
+ if (!match)
298
+ break;
299
+ const matchStart = searchFrom + match.index;
300
+ const tagEnd = matchStart + match[0].length;
301
+ // find the matching </Object>
302
+ let depth = 1;
303
+ let pos = tagEnd;
304
+ while (depth > 0 && pos < result.length) {
305
+ const openIdx = result.indexOf("<Object", pos);
306
+ const closeIdx = result.indexOf("</Object>", pos);
307
+ if (closeIdx < 0)
308
+ break;
309
+ if (openIdx >= 0 && openIdx < closeIdx) {
310
+ depth++;
311
+ pos = openIdx + 7;
312
+ }
313
+ else {
314
+ depth--;
315
+ if (depth === 0) {
316
+ // [matchStart .. closeIdx + 9] is the full <Object>...</Object>
317
+ const fullBlock = result.slice(matchStart, closeIdx + 9);
318
+ const inner = fullBlock.slice(match[0].length, fullBlock.length - 9);
319
+ const newInner = editFn(inner);
320
+ const newBlock = fullBlock.slice(0, match[0].length) + newInner + "</Object>";
321
+ result = result.slice(0, matchStart) + newBlock + result.slice(closeIdx + 9);
322
+ searchFrom = matchStart + newBlock.length;
323
+ }
324
+ else {
325
+ pos = closeIdx + 9;
326
+ }
327
+ }
328
+ }
329
+ if (depth > 0)
330
+ break; // safety: unmatched tag
331
+ }
332
+ return result;
333
+ }
334
+ export function cascadeRenameServerInLcn(lcnPath, className, oldName, newName) {
335
+ let content = readLatin1(lcnPath);
336
+ // 1. Rename in object channels
337
+ content = editObjectsOfClass(content, className, (inner) => {
338
+ const re = new RegExp(`(<Server\\s+Name\\s*=\\s*")${escapeRe(oldName)}(")`, "g");
339
+ return inner.replace(re, `$1${newName}$2`);
340
+ });
341
+ // 2. Rename in Connections: Destination="ObjName.OldName" → "ObjName.NewName"
342
+ // Resolve object->class from the .lcn object list and only rewrite connections whose object is an instance of the edited class.
343
+ const lcnInfo = parseLcn(lcnPath);
344
+ const objMap = new Map(); // object name -> class name
345
+ for (const obj of lcnInfo.objects) {
346
+ objMap.set(obj.name, obj.className);
347
+ }
348
+ const connectionRe = new RegExp(`(Destination\\s*=\\s*")([^".]+)\\.(${escapeRe(oldName)})(")`, "g");
349
+ content = content.replace(connectionRe, (match, prefix, objName, serverName, suffix) => {
350
+ const objClass = objMap.get(objName);
351
+ if (objClass === className) {
352
+ return `${prefix}${objName}.${newName}${suffix}`;
353
+ }
354
+ else if (!objClass) {
355
+ console.warn(`Warning: Could not resolve class for object "${objName}" in ${lcnPath} when renaming server "${oldName}" to "${newName}"`);
356
+ }
357
+ return match;
358
+ });
359
+ writeLatin1(lcnPath, content);
360
+ }
361
+ export function cascadeRenameClientInLcn(lcnPath, className, oldName, newName, objectNames) {
362
+ let content = readLatin1(lcnPath);
363
+ // 1. Rename in object channels
364
+ content = editObjectsOfClass(content, className, (inner) => {
365
+ const re = new RegExp(`(<Client\\s+Name\\s*=\\s*")${escapeRe(oldName)}(")`, "g");
366
+ return inner.replace(re, `$1${newName}$2`);
367
+ });
368
+ // 2. Rename in Connections Source="ObjName.OldClientName"
369
+ for (const objName of objectNames) {
370
+ content = content.replace(new RegExp(`(Source\\s*=\\s*")${escapeRe(objName)}\\.${escapeRe(oldName)}(")`, "g"), `$1${objName}.${newName}$2`);
371
+ }
372
+ writeLatin1(lcnPath, content);
373
+ }
374
+ export function cascadeRemoveClientFromLcn(lcnPath, className, clientName, objectNames) {
375
+ let content = readLatin1(lcnPath);
376
+ // 1. Remove from object channels
377
+ content = editObjectsOfClass(content, className, (inner) => inner.replace(clientLineRegex(clientName), ""));
378
+ // 2. Remove Connection lines that source from these objects' removed client
379
+ for (const objName of objectNames) {
380
+ content = content.replace(new RegExp(`[ \\t]*<Connection[^>]*Source\\s*=\\s*"${escapeRe(objName)}\\.${escapeRe(clientName)}"[^\\n]*\\n`, "g"), "");
381
+ }
382
+ writeLatin1(lcnPath, content);
383
+ }
384
+ export function cascadeRemoveServerFromLcn(lcnPath, className, serverName, objectNames) {
385
+ let content = readLatin1(lcnPath);
386
+ // 1. Remove from object channels
387
+ content = editObjectsOfClass(content, className, (inner) => inner.replace(serverLineRegex(serverName), ""));
388
+ // 2. Remove Connection lines that target these objects' removed server
389
+ for (const objName of objectNames) {
390
+ content = content.replace(new RegExp(`[ \\t]*<Connection[^>]*Destination\\s*=\\s*"${escapeRe(objName)}\\.${escapeRe(serverName)}"[^\\n]*\\n`, "g"), "");
391
+ }
392
+ writeLatin1(lcnPath, content);
393
+ }
394
+ // ─────────────────────────────────────────────────────────────────────────────
395
+ // Find objects of a class across all .lcn files
396
+ // ─────────────────────────────────────────────────────────────────────────────
397
+ export function findObjectsOfClass(lcnPaths, className) {
398
+ const result = new Map(); // lcnPath → object names
399
+ for (const lcnPath of lcnPaths) {
400
+ if (!existsSync(lcnPath))
401
+ continue;
402
+ const info = parseLcn(lcnPath);
403
+ const names = info.objects.filter((o) => o.className === className).map((o) => o.name);
404
+ if (names.length > 0)
405
+ result.set(lcnPath, names);
406
+ }
407
+ return result;
408
+ }
409
+ /** Split a .st file into its major sections. */
410
+ function splitStSections(content) {
411
+ const xmlEnd = (() => {
412
+ const si = content.indexOf("(*!");
413
+ if (si < 0)
414
+ return -1;
415
+ let depth = 1;
416
+ let pos = si + 3;
417
+ while (pos < content.length - 1) {
418
+ if (content[pos] === "(" && content[pos + 1] === "*") {
419
+ depth++;
420
+ pos += 2;
421
+ }
422
+ else if (content[pos] === "*" && content[pos + 1] === ")") {
423
+ depth--;
424
+ if (depth === 0)
425
+ return pos;
426
+ pos += 2;
427
+ }
428
+ else {
429
+ pos++;
430
+ }
431
+ }
432
+ return -1;
433
+ })();
434
+ if (xmlEnd < 0)
435
+ return { pre: content, classPart: "", implPart: "" };
436
+ const afterXml = xmlEnd + 2; // position after *)
437
+ const implIdx = content.indexOf("//}}LSL_DECLARATION", afterXml);
438
+ if (implIdx < 0) {
439
+ return {
440
+ pre: content.slice(0, afterXml),
441
+ classPart: content.slice(afterXml),
442
+ implPart: "",
443
+ };
444
+ }
445
+ return {
446
+ pre: content.slice(0, afterXml),
447
+ classPart: content.slice(afterXml, implIdx),
448
+ implPart: content.slice(implIdx),
449
+ };
450
+ }
451
+ /** Return the line range for a named section (e.g. "Variables") in the class body. */
452
+ function findSectionRange(text, sectionMarker) {
453
+ const markerRe = new RegExp(`\\/\\/\\s*${escapeRe(sectionMarker)}\\s*:`, "i");
454
+ const m = markerRe.exec(text);
455
+ if (!m)
456
+ return null;
457
+ const start = m.index + m[0].length; // character after the marker line's colon
458
+ // Find the next section marker line (// + word) or END_CLASS;.
459
+ // Use multiline mode with ^[ \t]* so n.index lands at the START of the line,
460
+ // not mid-line at the //, keeping the marker's leading whitespace in slice(range.end).
461
+ const nextSection = new RegExp(`^[ \\t]*(?:\\/\\/[A-Za-z]|END_CLASS\\s*;)`, "gm");
462
+ nextSection.lastIndex = start;
463
+ const n = nextSection.exec(text);
464
+ const end = n ? n.index : text.length;
465
+ return { start, end };
466
+ }
467
+ // ─────────────────────────────────────────────────────────────────────────────
468
+ // ST body channel type declarations (//Servers: and //Clients: sections)
469
+ // These are in sync with the XML block — update both together when adding channels.
470
+ // ─────────────────────────────────────────────────────────────────────────────
471
+ function editStBody(stPath, editFn) {
472
+ writeLatin1(stPath, editFn(readLatin1(stPath)));
473
+ }
474
+ function insertIntoSection(text, sectionMarker, newLine) {
475
+ const range = findSectionRange(text, sectionMarker);
476
+ if (!range)
477
+ return text; // section not found — leave unchanged
478
+ // Insert before the end of the section
479
+ return text.slice(0, range.end) + newLine + text.slice(range.end);
480
+ }
481
+ function removeLineFromSection(text, sectionMarker, namePattern) {
482
+ const range = findSectionRange(text, sectionMarker);
483
+ if (!range)
484
+ return text;
485
+ const section = text.slice(range.start, range.end);
486
+ const re = new RegExp(`[ \\t]*${escapeRe(namePattern)}[ \\t]*:[^\\n]*\\n`, "");
487
+ const newSection = section.replace(re, "");
488
+ return text.slice(0, range.start) + newSection + text.slice(range.end);
489
+ }
490
+ export function addServerTypeToStBody(stPath, name, stType) {
491
+ editStBody(stPath, (c) => {
492
+ const eol = c.includes("\r\n") ? "\r\n" : "\n";
493
+ return insertIntoSection(c, "Servers", `\t${name} \t: ${stType};${eol}`);
494
+ });
495
+ }
496
+ export function removeServerTypeFromStBody(stPath, name) {
497
+ editStBody(stPath, (c) => removeLineFromSection(c, "Servers", name));
498
+ }
499
+ export function renameServerInStBody(stPath, oldName, newName) {
500
+ editStBody(stPath, (c) => {
501
+ const { pre, classPart, implPart } = splitStSections(c);
502
+ const range = findSectionRange(classPart, "Servers");
503
+ if (!range)
504
+ return c;
505
+ const before = classPart.slice(0, range.start);
506
+ const section = classPart.slice(range.start, range.end);
507
+ const after = classPart.slice(range.end);
508
+ const newSection = section.replace(new RegExp(`\\b${escapeRe(oldName)}\\b(?=[ \\t]*:)`, "g"), newName);
509
+ const newClassPart = before + newSection + after;
510
+ return pre + newClassPart + implPart;
511
+ });
512
+ }
513
+ export function addClientTypeToStBody(stPath, name, stType) {
514
+ editStBody(stPath, (c) => {
515
+ const eol = c.includes("\r\n") ? "\r\n" : "\n";
516
+ return insertIntoSection(c, "Clients", `\t${name} \t: ${stType};${eol}`);
517
+ });
518
+ }
519
+ export function removeClientTypeFromStBody(stPath, name) {
520
+ editStBody(stPath, (c) => removeLineFromSection(c, "Clients", name));
521
+ }
522
+ export function renameClientInStBody(stPath, oldName, newName) {
523
+ editStBody(stPath, (c) => {
524
+ const { pre, classPart, implPart } = splitStSections(c);
525
+ const range = findSectionRange(classPart, "Clients");
526
+ if (!range)
527
+ return c;
528
+ const before = classPart.slice(0, range.start);
529
+ const section = classPart.slice(range.start, range.end);
530
+ const after = classPart.slice(range.end);
531
+ const newSection = section.replace(new RegExp(`\\b${escapeRe(oldName)}\\b(?=[ \\t]*:)`, "g"), newName);
532
+ const newClassPart = before + newSection + after;
533
+ return pre + newClassPart + implPart;
534
+ });
535
+ }
536
+ // ─────────────────────────────────────────────────────────────────────────────
537
+ // Variables
538
+ // ─────────────────────────────────────────────────────────────────────────────
539
+ export function addVariableToSt(stPath, name, type) {
540
+ editStBody(stPath, (c) => {
541
+ const eol = c.includes("\r\n") ? "\r\n" : "\n";
542
+ return insertIntoSection(c, "Variables", `\t\t${name} \t: ${type};${eol}`);
543
+ });
544
+ }
545
+ export function removeVariableFromSt(stPath, name) {
546
+ editStBody(stPath, (c) => removeLineFromSection(c, "Variables", name));
547
+ }
548
+ /**
549
+ * Rename a variable in the class declaration.
550
+ * If renameInBody is true, also renames whole-word occurrences in the
551
+ * implementation section (use with care — may affect unrelated identifiers).
552
+ * Returns the number of replacements made in the implementation section.
553
+ */
554
+ export function renameVariableInSt(stPath, oldName, newName, renameInBody = false) {
555
+ let bodyReplacements = 0;
556
+ editStBody(stPath, (content) => {
557
+ const { pre, classPart, implPart } = splitStSections(content);
558
+ // 1. Rename in the class declaration (variables section only)
559
+ const varRange = findSectionRange(classPart, "Variables");
560
+ let newClassPart = classPart;
561
+ if (varRange) {
562
+ const section = classPart.slice(varRange.start, varRange.end);
563
+ const newSection = section.replace(new RegExp(`\\b${escapeRe(oldName)}\\b`, "gi"), (m) => {
564
+ const newNameFirstChar = newName[0];
565
+ const oldNameFirstChar = oldName[0];
566
+ const matchFirstChar = m[0];
567
+ return newNameFirstChar && oldNameFirstChar && newNameFirstChar === oldNameFirstChar
568
+ ? newName
569
+ : matchFirstChar && matchFirstChar === matchFirstChar.toUpperCase()
570
+ ? (newName[0] ?? "").toUpperCase() + newName.slice(1)
571
+ : newName;
572
+ });
573
+ newClassPart = classPart.slice(0, varRange.start) + newSection + classPart.slice(varRange.end);
574
+ }
575
+ // 2. Optionally rename in the implementation section
576
+ let newImplPart = implPart;
577
+ if (renameInBody) {
578
+ const re = new RegExp(`\\b${escapeRe(oldName)}\\b`, "gi");
579
+ newImplPart = implPart.replace(re, (m) => {
580
+ bodyReplacements++;
581
+ // Preserve case of first letter
582
+ const newNameFirstChar = newName[0];
583
+ const oldNameFirstChar = oldName[0];
584
+ const matchFirstChar = m[0];
585
+ return newNameFirstChar && oldNameFirstChar && newNameFirstChar === oldNameFirstChar
586
+ ? newName
587
+ : matchFirstChar && matchFirstChar === matchFirstChar.toUpperCase()
588
+ ? (newName[0] ?? "").toUpperCase() + newName.slice(1)
589
+ : newName;
590
+ });
591
+ }
592
+ return pre + newClassPart + newImplPart;
593
+ });
594
+ return bodyReplacements;
595
+ }
596
+ function buildMethodDeclaration(opts, eol = "\n") {
597
+ const modStr = opts.modifiers?.length ? opts.modifiers.join(" ") + " " : "";
598
+ const lines = [`\tFUNCTION ${modStr}${opts.name}`];
599
+ const inputs = (opts.params ?? []).filter((p) => !p.direction || p.direction === "input");
600
+ const outputs = (opts.params ?? []).filter((p) => p.direction === "output");
601
+ const inouts = (opts.params ?? []).filter((p) => p.direction === "in_out");
602
+ if (inputs.length) {
603
+ lines.push("\t\tVAR_INPUT");
604
+ for (const p of inputs)
605
+ lines.push(`\t\t\t${p.name} \t: ${p.type};`);
606
+ lines.push("\t\tEND_VAR");
607
+ }
608
+ if (outputs.length) {
609
+ lines.push("\t\tVAR_OUTPUT");
610
+ for (const p of outputs)
611
+ lines.push(`\t\t\t${p.name} \t: ${p.type};`);
612
+ lines.push("\t\tEND_VAR");
613
+ }
614
+ if (inouts.length) {
615
+ lines.push("\t\tVAR_IN_OUT");
616
+ for (const p of inouts)
617
+ lines.push(`\t\t\t${p.name} \t: ${p.type};`);
618
+ lines.push("\t\tEND_VAR");
619
+ }
620
+ lines.push("\t\t;"); // declaration ends with ;
621
+ return lines.join(eol) + eol;
622
+ }
623
+ function buildMethodImplementation(className, opts, eol = "\n") {
624
+ const lines = [`FUNCTION ${className}::${opts.name}`];
625
+ const inputs = (opts.params ?? []).filter((p) => !p.direction || p.direction === "input");
626
+ const outputs = (opts.params ?? []).filter((p) => p.direction === "output");
627
+ const inouts = (opts.params ?? []).filter((p) => p.direction === "in_out");
628
+ if (inputs.length) {
629
+ lines.push("\tVAR_INPUT");
630
+ for (const p of inputs)
631
+ lines.push(`\t\t${p.name} \t: ${p.type};`);
632
+ lines.push("\tEND_VAR");
633
+ }
634
+ if (outputs.length) {
635
+ lines.push("\tVAR_OUTPUT");
636
+ for (const p of outputs)
637
+ lines.push(`\t\t${p.name} \t: ${p.type};`);
638
+ lines.push("\tEND_VAR");
639
+ }
640
+ if (inouts.length) {
641
+ lines.push("\tVAR_IN_OUT");
642
+ for (const p of inouts)
643
+ lines.push(`\t\t${p.name} \t: ${p.type};`);
644
+ lines.push("\tEND_VAR");
645
+ }
646
+ lines.push("");
647
+ lines.push("\t" + (opts.body ?? `// TODO: implement ${opts.name}`));
648
+ lines.push("");
649
+ lines.push("END_FUNCTION");
650
+ return lines.join(eol) + eol + eol;
651
+ }
652
+ export function addMethodToSt(stPath, className, opts) {
653
+ editStBody(stPath, (content) => {
654
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
655
+ const { pre, classPart, implPart } = splitStSections(content);
656
+ // 1. Add declaration to //Functions: section (before //Tables: or END_CLASS;)
657
+ const funcRange = findSectionRange(classPart, "Functions");
658
+ let newClassPart = classPart;
659
+ if (funcRange) {
660
+ const decl = buildMethodDeclaration(opts, eol);
661
+ newClassPart = classPart.slice(0, funcRange.end) + decl + classPart.slice(funcRange.end);
662
+ }
663
+ // 2. Add implementation to end of implPart
664
+ const impl = buildMethodImplementation(className, opts, eol);
665
+ const newImplPart = implPart + impl;
666
+ return pre + newClassPart + newImplPart;
667
+ });
668
+ }
669
+ /** Find a method implementation block: FUNCTION ClassName::Name ... END_FUNCTION */
670
+ function findMethodImplRange(implPart, className, methodName) {
671
+ const headerRe = new RegExp(`FUNCTION\\s+${escapeRe(className)}::${escapeRe(methodName)}\\b`);
672
+ const m = headerRe.exec(implPart);
673
+ if (!m)
674
+ return null;
675
+ const start = m.index;
676
+ const endRe = /\bEND_FUNCTION\b/g;
677
+ endRe.lastIndex = start;
678
+ const em = endRe.exec(implPart);
679
+ if (!em)
680
+ return null;
681
+ // include trailing newlines
682
+ let end = em.index + em[0].length;
683
+ while (end < implPart.length && (implPart[end] === "\r" || implPart[end] === "\n"))
684
+ end++;
685
+ return { start, end };
686
+ }
687
+ /** Find a method declaration in the Functions section (multi-line, ends with ;) */
688
+ function findMethodDeclRange(classPart, methodName) {
689
+ const range = findSectionRange(classPart, "Functions");
690
+ if (!range)
691
+ return null;
692
+ const section = classPart.slice(range.start, range.end);
693
+ // Match FUNCTION [modifiers] methodName
694
+ const headerRe = new RegExp(`[ \\t]*FUNCTION(?:[\\s\\w]*?)\\s+${escapeRe(methodName)}\\b`);
695
+ const m = headerRe.exec(section);
696
+ if (!m)
697
+ return null;
698
+ const declStart = range.start + m.index;
699
+ // Scan forward tracking VAR block depth to find the closing ; at depth 0.
700
+ // Params have ; inside VAR_INPUT/VAR_OUTPUT/VAR_IN_OUT blocks (depth > 0).
701
+ // The declaration-closing ; is always at depth 0 (on the FUNCTION line itself
702
+ // for no-param methods, or on a separate line / end of last END_VAR for parameterised ones).
703
+ const tokenRe = /\bVAR(?:_INPUT|_OUTPUT|_IN_OUT)\b|\bEND_VAR\b|;/g;
704
+ tokenRe.lastIndex = range.start + m.index + m[0].length;
705
+ let depth = 0;
706
+ let token;
707
+ while ((token = tokenRe.exec(classPart)) !== null) {
708
+ if (token.index >= range.end)
709
+ break;
710
+ if (token[0] === "END_VAR") {
711
+ depth--;
712
+ }
713
+ else if (token[0] !== ";") {
714
+ depth++; // VAR_INPUT, VAR_OUTPUT, or VAR_IN_OUT
715
+ }
716
+ else if (depth === 0) {
717
+ let declEnd = token.index + 1;
718
+ while (declEnd < classPart.length && (classPart[declEnd] === "\r" || classPart[declEnd] === "\n"))
719
+ declEnd++;
720
+ return { start: declStart, end: declEnd };
721
+ }
722
+ }
723
+ return null;
724
+ }
725
+ export function removeMethodFromSt(stPath, className, name) {
726
+ editStBody(stPath, (content) => {
727
+ const { pre, classPart, implPart } = splitStSections(content);
728
+ // Remove declaration
729
+ const declRange = findMethodDeclRange(classPart, name);
730
+ let newClassPart = classPart;
731
+ if (declRange) {
732
+ newClassPart = classPart.slice(0, declRange.start) + classPart.slice(declRange.end);
733
+ }
734
+ // Remove implementation
735
+ const implRange = findMethodImplRange(implPart, className, name);
736
+ let newImplPart = implPart;
737
+ if (implRange) {
738
+ newImplPart = implPart.slice(0, implRange.start) + implPart.slice(implRange.end);
739
+ }
740
+ return pre + newClassPart + newImplPart;
741
+ });
742
+ }
743
+ export function renameMethodInSt(stPath, className, oldName, newName) {
744
+ editStBody(stPath, (content) => {
745
+ const { pre, classPart, implPart } = splitStSections(content);
746
+ // 1. Rename in the Functions section declaration header
747
+ const funcRange = findSectionRange(classPart, "Functions");
748
+ let newClassPart = classPart;
749
+ if (funcRange) {
750
+ const section = classPart.slice(funcRange.start, funcRange.end);
751
+ const newSection = section.replace(new RegExp(`(FUNCTION(?:[\\s\\w]*)\\s+)${escapeRe(oldName)}\\b`, "g"), `$1${newName}`);
752
+ newClassPart = classPart.slice(0, funcRange.start) + newSection + classPart.slice(funcRange.end);
753
+ }
754
+ // 2. Rename implementation header: FUNCTION ClassName::OldName
755
+ const newImplPart = implPart.replace(new RegExp(`(FUNCTION\\s+${escapeRe(className)}::)${escapeRe(oldName)}\\b`, "g"), `$1${newName}`);
756
+ return pre + newClassPart + newImplPart;
757
+ });
758
+ }