@tsplc/build 0.0.0-stage → 0.2.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1696 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1683 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +18 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/edge-gateway/Dockerfile +17 -0
  17. package/docker/entrypoint.sh +10 -0
  18. package/docker/install/NOTICE.md +21 -0
  19. package/docker/install/core.sh +36 -0
  20. package/docker/install/decode.c +12 -0
  21. package/docker/install/device/Runtime.ico +0 -0
  22. package/docker/install/device/Runtime.png +0 -0
  23. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  24. package/docker/install/extract.py +43 -0
  25. package/docker/install/helper-launcher.c +46 -0
  26. package/docker/install/install.sh +94 -0
  27. package/docker/install/prerequisites.sh +15 -0
  28. package/docker/install/verify.py +133 -0
  29. package/docker/install/verify.sh +34 -0
  30. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  31. package/docker/install/wine-ncrypt/README.md +28 -0
  32. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  33. package/docker/install/wine-ncrypt/main.c +619 -0
  34. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  35. package/docker/install/wine-signal/README.md +22 -0
  36. package/docker/install/wine-signal/build.sh +10 -0
  37. package/docker/install/wine-signal/check.sh +34 -0
  38. package/docker/install/wine-signal/noop-i386.c +2 -0
  39. package/docker/install/wine-signal/signal-state.c +105 -0
  40. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  41. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  42. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  43. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  44. package/docker/install/wine-signal/wine +4 -0
  45. package/docker/runner.ts +156 -0
  46. package/package.json +53 -4
  47. package/python/build.py +660 -0
  48. package/python/codesys_login.py +100 -0
  49. package/python/codesys_online.py +327 -0
  50. package/python/test_build.py +444 -0
  51. package/python/test_codesys_login.py +120 -0
  52. package/python/test_codesys_online.py +184 -0
  53. package/python/test_installer_packages.py +216 -0
  54. package/python/test_installer_verification.py +307 -0
  55. package/python/test_worker.py +142 -0
  56. package/python/worker.py +102 -0
  57. package/runtime/install.sh +74 -0
  58. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  59. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  60. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  61. package/src/artifacts.ts +77 -0
  62. package/src/build.ts +87 -0
  63. package/src/cli.ts +116 -0
  64. package/src/config.ts +12 -0
  65. package/src/controller-online.ts +297 -0
  66. package/src/controller-session.ts +298 -0
  67. package/src/diagnostics.ts +68 -0
  68. package/src/docker-build.ts +163 -0
  69. package/src/docker-context.ts +35 -0
  70. package/src/docker-run.ts +103 -0
  71. package/src/host/codesys-command.ts +45 -0
  72. package/src/host/codesys-host.ts +145 -0
  73. package/src/host/codesys-worker.ts +348 -0
  74. package/src/host/windows-path.ts +73 -0
  75. package/src/index.ts +43 -0
  76. package/src/io.ts +127 -0
  77. package/src/libraries.ts +166 -0
  78. package/src/package-path.ts +5 -0
  79. package/src/run.ts +133 -0
  80. package/src/runtime-status.ts +21 -0
  81. package/src/sources.ts +248 -0
  82. package/src/st/declarations.ts +229 -0
  83. package/src/st/lexer.ts +125 -0
  84. package/src/st/parse.ts +202 -0
  85. package/src/st/reader.ts +50 -0
@@ -0,0 +1,166 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import type { LibraryDependency } from "@tsplc/config";
5
+
6
+ export const libraryRegistry = "https://store-archive.codesys.com/CODESYSLibs";
7
+ const libraryFile = /\.(?:compiled-library(?:-v\d+)?|library)$/i;
8
+ type Fetcher = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+ export interface InstalledLibrary {
10
+ title: string;
11
+ company?: string;
12
+ version?: string;
13
+ namespace?: string;
14
+ path: string;
15
+ sha256: string;
16
+ url?: string;
17
+ }
18
+ interface LibraryLock {
19
+ version: 1;
20
+ dependencies: Record<string, InstalledLibrary>;
21
+ }
22
+ const hash = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
23
+ const component = (name: string) => {
24
+ if (!name.trim() || /[/\\\0\r\n]/.test(name) || name === "." || name === "..")
25
+ throw new Error(`Invalid library registry component: ${name}.`);
26
+ return encodeURIComponent(name);
27
+ };
28
+ async function response(url: string, fetcher: Fetcher) {
29
+ const result = await fetcher(url, { signal: AbortSignal.timeout(30_000) });
30
+ if (!result.ok) throw new Error(`Library registry ${result.status}: ${url}`);
31
+ return result;
32
+ }
33
+ export async function libraryIndex(
34
+ path: string[] = ["System"],
35
+ fetcher: Fetcher = fetch,
36
+ ): Promise<string[]> {
37
+ const url = `${libraryRegistry}/${path.map(component).join("/")}/index`;
38
+ const result = await response(url, fetcher);
39
+ const text = await result.text();
40
+ if (text.length > 4_000_000 || /<html/i.test(text))
41
+ throw new Error(`Invalid library registry index: ${url}`);
42
+ return text
43
+ .split(/\r?\n/)
44
+ .map((s) => s.trim())
45
+ .filter(Boolean);
46
+ }
47
+ export async function validateLibrary(
48
+ title: string,
49
+ version: string,
50
+ company = "System",
51
+ fetcher: Fetcher = fetch,
52
+ ) {
53
+ if (!(await libraryIndex([company], fetcher)).includes(title))
54
+ throw new Error(`Unknown CODESYS library ${company}/${title}. Names are case sensitive.`);
55
+ const versions = await libraryIndex([company, title], fetcher);
56
+ if (!versions.includes(version))
57
+ throw new Error(`Unknown version ${version} for ${title}. Available: ${versions.join(", ")}`);
58
+ const filenames = (await libraryIndex([company, title, version], fetcher)).filter((name) =>
59
+ libraryFile.test(name),
60
+ );
61
+ if (filenames.length !== 1)
62
+ throw new Error(
63
+ `Expected one library payload for ${title} ${version}, received ${filenames.length}.`,
64
+ );
65
+ return {
66
+ filename: filenames[0]!,
67
+ url: `${libraryRegistry}/${[company, title, version, filenames[0]!].map(component).join("/")}`,
68
+ };
69
+ }
70
+ export async function installLibraries(
71
+ dependencies: Record<string, LibraryDependency>,
72
+ root: string,
73
+ options: { frozen?: boolean; fetcher?: Fetcher } = {},
74
+ ) {
75
+ const file = resolve(root, "tsplc.lock.json"),
76
+ store = resolve(root, ".tsplc/libraries");
77
+ let previous: LibraryLock = { version: 1, dependencies: {} };
78
+ try {
79
+ previous = JSON.parse(await readFile(file, "utf8"));
80
+ } catch (cause) {
81
+ if (!(cause && typeof cause === "object" && "code" in cause && cause.code === "ENOENT"))
82
+ throw cause;
83
+ }
84
+ if (previous.version !== 1 || !previous.dependencies || typeof previous.dependencies !== "object")
85
+ throw new Error("Invalid tsplc.lock.json.");
86
+ const installed: Record<string, InstalledLibrary> = {};
87
+ const fetcher = options.fetcher ?? fetch;
88
+ await mkdir(store, { recursive: true });
89
+ for (const [title, dependency] of Object.entries(dependencies)) {
90
+ component(title);
91
+ const settings = typeof dependency === "string" ? { version: dependency } : dependency;
92
+ const locked = previous.dependencies[title];
93
+ const prior = locked ? { ...locked, path: resolve(root, locked.path) } : undefined;
94
+ const same =
95
+ prior !== undefined &&
96
+ prior.namespace === settings.namespace &&
97
+ ("path" in settings
98
+ ? prior.url === undefined && prior.path === resolve(root, settings.path)
99
+ : prior.version === settings.version && prior.company === (settings.company ?? "System"));
100
+ if (same) {
101
+ try {
102
+ if (hash(await readFile(prior.path)) === prior.sha256) {
103
+ installed[title] = prior;
104
+ continue;
105
+ }
106
+ } catch {
107
+ /* Missing cache can be restored from the exact URL. */
108
+ }
109
+ if (options.frozen) throw new Error(`Locked library ${title} is missing or changed.`);
110
+ } else if (options.frozen) throw new Error(`Dependency ${title} differs from tsplc.lock.json.`);
111
+ if ("path" in settings) {
112
+ const path = resolve(root, settings.path);
113
+ if (!libraryFile.test(path))
114
+ throw new Error(`${title}: expected a .library or .compiled-library file.`);
115
+ installed[title] = {
116
+ title,
117
+ path,
118
+ sha256: hash(await readFile(path)),
119
+ ...(settings.namespace ? { namespace: settings.namespace } : {}),
120
+ };
121
+ } else {
122
+ const company = settings.company ?? "System";
123
+ const payload = await validateLibrary(title, settings.version, company, fetcher);
124
+ const result = await response(payload.url, fetcher);
125
+ const data = new Uint8Array(await result.arrayBuffer());
126
+ if (!data.length || data.length > 256 * 1024 * 1024)
127
+ throw new Error(`${title}: invalid library payload size.`);
128
+ const sha256 = hash(data);
129
+ // An existing lock pins the payload, even if the registry later changes it.
130
+ if (same && prior.sha256 !== sha256)
131
+ throw new Error(
132
+ `Registry payload changed for locked library ${title} ${settings.version}.`,
133
+ );
134
+ const path = resolve(store, `${sha256}-${basename(payload.filename)}`),
135
+ temp = `${path}.${randomUUID()}.tmp`;
136
+ await writeFile(temp, data, { flag: "wx" });
137
+ await rename(temp, path);
138
+ installed[title] = {
139
+ title,
140
+ company,
141
+ version: settings.version,
142
+ path,
143
+ sha256,
144
+ url: payload.url,
145
+ ...(settings.namespace ? { namespace: settings.namespace } : {}),
146
+ };
147
+ }
148
+ }
149
+ if (
150
+ options.frozen &&
151
+ Object.keys(previous.dependencies).some((name) => !Object.hasOwn(dependencies, name))
152
+ )
153
+ throw new Error("Removed dependencies differ from tsplc.lock.json.");
154
+ if (!options.frozen) {
155
+ const temp = `${file}.${randomUUID()}.tmp`;
156
+ const portable = Object.fromEntries(
157
+ Object.entries(installed).map(([title, library]) => [
158
+ title,
159
+ { ...library, path: relative(root, library.path).replaceAll("\\", "/") },
160
+ ]),
161
+ );
162
+ await writeFile(temp, JSON.stringify({ version: 1, dependencies: portable }, null, 2) + "\n");
163
+ await rename(temp, file);
164
+ }
165
+ return Object.values(installed);
166
+ }
@@ -0,0 +1,5 @@
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Both source modules and published bundles live one directory below the package root.
5
+ export const packageDirectory = resolve(fileURLToPath(new URL("..", import.meta.url)));
package/src/run.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ import { readBuild } from "./artifacts";
9
+ import type { ResolvedConfig } from "./config";
10
+ import { runtimeImage } from "./docker-build";
11
+ import { withWorkerLock } from "./host/codesys-worker";
12
+ import { getApplicationStatus } from "./runtime-status";
13
+
14
+ const execute = promisify(execFile);
15
+ const stateSchema = z.object({
16
+ Running: z.boolean(),
17
+ StartedAt: z.iso.datetime({ offset: true }),
18
+ });
19
+
20
+ const service = "plc";
21
+
22
+ /** The Docker Compose project of a tsPLC project: `FrameAssembly` runs as `frame-assembly`. */
23
+ export const runtimeProject = (name: string) =>
24
+ name
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9_-]+/g, "-");
28
+
29
+ /** The local PLC: named volumes keep its provisioning and the installed application across restarts. */
30
+ export const runtimeDefinition = (name: string) => ({
31
+ services: {
32
+ [service]: {
33
+ image: runtimeImage,
34
+ platform: "linux/amd64",
35
+ hostname: `${runtimeProject(name)}-${service}`,
36
+ init: true,
37
+ ports: ["127.0.0.1:4840:4840", "127.0.0.1:11740:11740"],
38
+ cap_add: ["SYS_NICE", "IPC_LOCK"],
39
+ volumes: ["control-conf:/conf/codesyscontrol", "control-data:/data/codesyscontrol"],
40
+ restart: "no",
41
+ },
42
+ },
43
+ volumes: { "control-conf": {}, "control-data": {} },
44
+ });
45
+
46
+ /** Deploy the completed boot application to the project's local PLC container. */
47
+ export const runProject = async (
48
+ config: Pick<ResolvedConfig, "outDir" | "timeouts">,
49
+ name: string,
50
+ ) =>
51
+ withWorkerLock(config, async () => {
52
+ // Validate before stopping anything. A missing receipt includes a failed rebuild.
53
+ const build = await readBuild(config.outDir).catch((cause: unknown) => {
54
+ throw new Error(`No complete build in ${config.outDir}. Run tsplc-build build first.`, {
55
+ cause,
56
+ });
57
+ });
58
+ const definitionDirectory = await mkdtemp(join(tmpdir(), "tsplc-runtime-"));
59
+ try {
60
+ const composeFile = join(definitionDirectory, "compose.yaml");
61
+ // JSON is valid YAML.
62
+ await writeFile(composeFile, JSON.stringify(runtimeDefinition(name)));
63
+ const docker = (...args: string[]) =>
64
+ execute("docker", args, {
65
+ timeout: config.timeouts.runtime,
66
+ maxBuffer: 8 * 1024 * 1024,
67
+ });
68
+ const compose = (...args: string[]) =>
69
+ docker("compose", "--project-name", runtimeProject(name), "--file", composeFile, ...args);
70
+ const installer = await readFile(new URL("../runtime/install.sh", import.meta.url), "utf8");
71
+ console.log(`Installing ${build.application} in Docker service ${service}`);
72
+ await compose("stop", service);
73
+ // The helper uses the service's volumes without starting its PLC or publishing
74
+ // ports. Its read-only artifact mount is removed with the helper container.
75
+ await compose(
76
+ "run",
77
+ "--rm",
78
+ "--no-deps",
79
+ "-T",
80
+ "--volume",
81
+ `${dirname(build.output)}:/codesys-build:ro`,
82
+ "--entrypoint",
83
+ "/bin/sh",
84
+ service,
85
+ "-c",
86
+ installer,
87
+ "codesys-build-install",
88
+ build.application,
89
+ basename(build.output, ".app"),
90
+ );
91
+ await compose("up", "--detach", "--no-deps", service);
92
+ const containers = (await compose("ps", "--all", "--quiet", service)).stdout
93
+ .trim()
94
+ .split(/\s+/)
95
+ .filter(Boolean);
96
+ if (containers.length !== 1 || !containers[0]) {
97
+ throw new Error(`Expected one container for Compose service ${service}`);
98
+ }
99
+ const container = containers[0];
100
+ const deadline = Date.now() + config.timeouts.runtime;
101
+ while (true) {
102
+ const state = stateSchema.parse(
103
+ JSON.parse((await docker("inspect", "--format", "{{json .State}}", container)).stdout),
104
+ );
105
+ if (!state.Running) {
106
+ throw new Error(
107
+ `Docker service ${service} exited before the application started. Check docker logs ${container}.`,
108
+ );
109
+ }
110
+ // Use this container start time, not the host clock or an old "started" event.
111
+ const { stdout: log } = await docker(
112
+ "exec",
113
+ container,
114
+ "/bin/sh",
115
+ "-c",
116
+ "if [ -f /data/codesyscontrol/codesyscontrol.log ]; then cat /data/codesyscontrol/codesyscontrol.log; fi",
117
+ );
118
+ const status = getApplicationStatus(log, build.application, Date.parse(state.StartedAt));
119
+ if (status === "running") {
120
+ console.log(`${build.application} is running in ${service}`);
121
+ return { ...build, container };
122
+ }
123
+ if (status === "failed" || Date.now() >= deadline) {
124
+ throw new Error(
125
+ `CODESYS did not start ${build.application}. Runtime log:\n${log.split("\n").slice(-25).join("\n")}`,
126
+ );
127
+ }
128
+ await delay(250);
129
+ }
130
+ } finally {
131
+ await rm(definitionDirectory, { force: true, recursive: true });
132
+ }
133
+ });
@@ -0,0 +1,21 @@
1
+ /** Runtime stdout is buffered in Docker. Its log file records start/stop events immediately. */
2
+ export const getApplicationStatus = (log: string, application: string, startedAt: number) => {
3
+ const event = log
4
+ .split(/\r?\n/)
5
+ .reverse()
6
+ .find((line) => {
7
+ const timestamp = Date.parse(line.split(",")[0] ?? "");
8
+ if (!Number.isFinite(timestamp) || timestamp < startedAt) {
9
+ return false;
10
+ }
11
+ return (
12
+ line.includes(`<app>${application}</app>`) &&
13
+ /\b(started|stopped|exception|corrupt|failed|denied)\b/i.test(line) &&
14
+ !line.includes("load started")
15
+ );
16
+ });
17
+ if (!event) {
18
+ return "pending";
19
+ }
20
+ return event.includes(`Application [<app>${application}</app>] started`) ? "running" : "failed";
21
+ };
package/src/sources.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import type { ResolvedConfig } from "./config";
4
+ import { type ObjectKind, type ParsedSource, parseSource } from "./st/parse";
5
+ import { bindStructuredTextIo } from "./io.ts";
6
+
7
+ export type SourceObject = ParsedSource & {
8
+ source: string;
9
+ folder: string[];
10
+ children: SourceObject[];
11
+ };
12
+ type Files = {
13
+ combined: string[];
14
+ declaration: string[];
15
+ implementation: string[];
16
+ };
17
+ const prefixes: Record<string, ObjectKind> = {
18
+ FB: "function_block",
19
+ PRG: "program",
20
+ FUN: "function",
21
+ F: "function",
22
+ I: "interface",
23
+ ST: "dut",
24
+ E: "dut",
25
+ T: "dut",
26
+ U: "dut",
27
+ GVL: "gvl",
28
+ };
29
+ const childFolders: Record<string, ObjectKind> = {
30
+ methods: "method",
31
+ properties: "property",
32
+ actions: "action",
33
+ };
34
+ const canOwn = (kind: ObjectKind) =>
35
+ ["program", "function_block", "interface", "property"].includes(kind);
36
+ const assertUnique = (objects: SourceObject[], scope: string) => {
37
+ const seen = new Map<string, string>();
38
+ for (const object of objects) {
39
+ const key = object.name.toLowerCase();
40
+ const previous = seen.get(key);
41
+ if (previous) {
42
+ throw new Error(
43
+ `Duplicate IEC name ${object.name} in ${scope}: ${previous} and ${object.source}`,
44
+ );
45
+ }
46
+ seen.set(key, object.source);
47
+ assertUnique(object.children, `${scope}.${object.name}`);
48
+ }
49
+ };
50
+
51
+ const readUnit = async (
52
+ name: string,
53
+ files: Files,
54
+ role?: ObjectKind,
55
+ interfaceMember = false,
56
+ ): Promise<ParsedSource & { source: string }> => {
57
+ if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(name)) {
58
+ throw new Error(`Invalid IEC object name: ${name}`);
59
+ }
60
+ for (const [part, candidates] of Object.entries(files)) {
61
+ if (candidates.length > 1) {
62
+ throw new Error(`Ambiguous ${part} for ${name}: ${candidates.join(", ")}`);
63
+ }
64
+ }
65
+ const combined = files.combined[0];
66
+ const declaration = files.declaration[0];
67
+ const implementation = files.implementation[0];
68
+ if (combined && (declaration || implementation)) {
69
+ throw new Error(`Both combined and split sources exist for ${name}`);
70
+ }
71
+ const path = combined ?? declaration ?? implementation;
72
+ if (!path) {
73
+ throw new Error(`Missing source for ${name}`);
74
+ }
75
+ if (role === "action") {
76
+ if (!combined) {
77
+ throw new Error(`Actions use one implementation-only .st file: ${path}`);
78
+ }
79
+ return {
80
+ kind: "action",
81
+ name,
82
+ declaration: "",
83
+ implementation: await readFile(combined, "utf8"),
84
+ source: combined,
85
+ };
86
+ }
87
+ if (role === "get" || role === "set") {
88
+ if (!combined) {
89
+ throw new Error(`Property accessors use one .st file: ${path}`);
90
+ }
91
+ const body = await readFile(combined, "utf8");
92
+ const header = `METHOD ${name}\n`;
93
+ const parsed = parseSource(`${header}${body}\nEND_METHOD`, {
94
+ filename: combined,
95
+ });
96
+ return {
97
+ ...parsed,
98
+ kind: role,
99
+ name,
100
+ declaration: parsed.declaration.slice(header.length),
101
+ source: combined,
102
+ };
103
+ }
104
+ if (!combined && !declaration) {
105
+ throw new Error(`Missing declaration for ${path}`);
106
+ }
107
+ const sourcePath = combined ?? declaration ?? path;
108
+ const parsed = parseSource(await readFile(sourcePath, "utf8"), {
109
+ filename: sourcePath,
110
+ name,
111
+ declarationOnly: !combined || interfaceMember,
112
+ });
113
+ if (parsed.name.toLowerCase() !== name.toLowerCase()) {
114
+ throw new Error(`Source name ${name} does not match declaration ${parsed.name}: ${sourcePath}`);
115
+ }
116
+ if (role && parsed.kind !== role) {
117
+ throw new Error(`Expected ${role}, found ${parsed.kind}: ${sourcePath}`);
118
+ }
119
+ if (!role && ["method", "property"].includes(parsed.kind)) {
120
+ throw new Error(`Child object has no enclosing POU: ${sourcePath}`);
121
+ }
122
+ const prefix = /^([A-Z]+)_/.exec(name.toUpperCase())?.[1];
123
+ if (!role && prefix && prefixes[prefix] && prefixes[prefix] !== parsed.kind) {
124
+ throw new Error(`Prefix ${prefix}_ conflicts with ${parsed.kind}: ${sourcePath}`);
125
+ }
126
+ const declarationOnly =
127
+ ["dut", "gvl", "interface", "property"].includes(parsed.kind) || interfaceMember;
128
+ if (declarationOnly && implementation) {
129
+ throw new Error(`This object has no implementation: ${implementation}`);
130
+ }
131
+ if (!combined && !declarationOnly && !implementation) {
132
+ throw new Error(`Missing implementation for ${sourcePath}`);
133
+ }
134
+ return {
135
+ ...parsed,
136
+ ...(implementation ? { implementation: await readFile(implementation, "utf8") } : {}),
137
+ source: sourcePath,
138
+ };
139
+ };
140
+
141
+ /** Folder placement organizes the IDE; it never changes IEC names or execution order. */
142
+ export const loadSources = async (
143
+ config: Pick<ResolvedConfig, "sourceDir" | "entry" | "io" | "tasks">,
144
+ ) => {
145
+ const objects: SourceObject[] = [];
146
+ const visit = async (
147
+ directory: string,
148
+ folder: string[],
149
+ parent?: SourceObject,
150
+ role?: ObjectKind,
151
+ ): Promise<void> => {
152
+ const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) =>
153
+ a.name.localeCompare(b.name, "en"),
154
+ );
155
+ const groups = new Map<string, { name: string; files: Files }>();
156
+ for (const entry of entries) {
157
+ if (entry.isSymbolicLink()) {
158
+ throw new Error(`Source symlinks are not supported: ${resolve(directory, entry.name)}`);
159
+ }
160
+ if (!entry.isFile() || !/\.st$/i.test(entry.name)) {
161
+ continue;
162
+ }
163
+ const stem = entry.name.slice(0, -3);
164
+ const suffix = /^(.*)\.(declaration|implementation)$/.exec(stem);
165
+ const generic = ["source", "declaration", "implementation"].includes(stem);
166
+ const name = generic ? basename(directory) : (suffix?.[1] ?? stem);
167
+ const section = suffix?.[2] ?? stem;
168
+ const part = section === "declaration" || section === "implementation" ? section : "combined";
169
+ const key = name.toLowerCase();
170
+ const group = groups.get(key) ?? {
171
+ name,
172
+ files: { combined: [], declaration: [], implementation: [] },
173
+ };
174
+ group.files[part].push(resolve(directory, entry.name));
175
+ groups.set(key, group);
176
+ }
177
+ const units: SourceObject[] = [];
178
+ const ownKey = basename(directory).toLowerCase();
179
+ // Read an object's own source before its Get/Set files, which have no POU header.
180
+ const ordered = [...groups].sort(([a], [b]) => Number(b === ownKey) - Number(a === ownKey));
181
+ for (const [key, { name, files }] of ordered) {
182
+ const ownProperty = units.find(
183
+ (unit) => unit.kind === "property" && unit.name.toLowerCase() === ownKey,
184
+ );
185
+ const accessor = ownProperty && (key === "get" || key === "set") ? key : undefined;
186
+ const unit: SourceObject = {
187
+ ...(await readUnit(
188
+ accessor ? `${accessor[0]?.toUpperCase()}${accessor.slice(1)}` : name,
189
+ files,
190
+ accessor ?? role,
191
+ parent?.kind === "interface",
192
+ )),
193
+ folder: parent ? [] : folder,
194
+ children: [],
195
+ };
196
+ if (accessor && ownProperty) {
197
+ if (parent?.kind === "interface") {
198
+ throw new Error(`Interface properties cannot implement accessors: ${unit.source}`);
199
+ }
200
+ ownProperty.children.push(unit);
201
+ } else {
202
+ units.push(unit);
203
+ if (parent) {
204
+ parent.children.push(unit);
205
+ } else {
206
+ objects.push(unit);
207
+ }
208
+ }
209
+ }
210
+ const own = units.find((unit) => unit.name.toLowerCase() === ownKey && canOwn(unit.kind));
211
+ if (own && !parent) {
212
+ own.folder = folder.slice(0, -1);
213
+ }
214
+ for (const entry of entries.filter((entry) => entry.isDirectory())) {
215
+ const childRole = childFolders[entry.name];
216
+ if (own && childRole) {
217
+ if (own.kind === "property" || (own.kind === "interface" && childRole === "action")) {
218
+ throw new Error(`Unsupported ${entry.name} under ${own.name}`);
219
+ }
220
+ await visit(resolve(directory, entry.name), [], own, childRole);
221
+ } else if (parent) {
222
+ await visit(resolve(directory, entry.name), [], parent, role);
223
+ } else {
224
+ await visit(resolve(directory, entry.name), [...folder, entry.name]);
225
+ }
226
+ }
227
+ };
228
+ await visit(resolve(config.sourceDir), []);
229
+ assertUnique(objects, "Application");
230
+ if (config.io) bindStructuredTextIo(objects, config.io);
231
+ const entry = objects.find((object) => object.source === resolve(config.entry));
232
+ if (!entry) {
233
+ throw new Error(`Entry is not a discovered source: ${config.entry}`);
234
+ }
235
+ if (entry.kind !== "program") {
236
+ throw new Error(`Entry must be a PROGRAM: ${config.entry}`);
237
+ }
238
+ if (config.tasks) {
239
+ const programs = new Set(
240
+ objects.filter((object) => object.kind === "program").map((object) => object.name),
241
+ );
242
+ for (const task of config.tasks)
243
+ for (const program of task.programs)
244
+ if (!programs.has(program))
245
+ throw new Error(`Task ${task.name} references an undiscovered PROGRAM ${program}`);
246
+ }
247
+ return { entry: entry.name, objects };
248
+ };