@tsplc/compiler 0.0.0-stage → 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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -2
  3. package/backends/codesys/config.ts +54 -0
  4. package/backends/codesys/emit.ts +1063 -0
  5. package/backends/codesys/engineering.ts +88 -0
  6. package/backends/codesys/http.ts +598 -0
  7. package/backends/codesys/index.ts +12 -0
  8. package/backends/codesys/io-paths.ts +103 -0
  9. package/backends/codesys/names.ts +76 -0
  10. package/backends/codesys/numeric.ts +356 -0
  11. package/backends/codesys/plan.ts +512 -0
  12. package/backends/codesys/publication.ts +59 -0
  13. package/backends/codesys/storage.ts +63 -0
  14. package/backends/codesys/task-access.ts +118 -0
  15. package/backends/codesys/types.ts +178 -0
  16. package/backends/codesys/vendor.ts +108 -0
  17. package/dist/cli.js +740 -0
  18. package/dist/cli.js.map +13 -0
  19. package/dist/clock.js +8 -0
  20. package/dist/clock.js.map +10 -0
  21. package/dist/codesys.js +2587 -0
  22. package/dist/codesys.js.map +22 -0
  23. package/dist/elaborate-capture.js +118 -0
  24. package/dist/elaborate-capture.js.map +10 -0
  25. package/dist/index.js +6859 -0
  26. package/dist/index.js.map +25 -0
  27. package/dist/ir.js +1888 -0
  28. package/dist/ir.js.map +22 -0
  29. package/dist/math.js +24 -0
  30. package/dist/math.js.map +10 -0
  31. package/dist/project.js +423 -0
  32. package/dist/project.js.map +11 -0
  33. package/ir/aggregates.ts +333 -0
  34. package/ir/allocation.ts +74 -0
  35. package/ir/core-model.ts +116 -0
  36. package/ir/core.ts +63 -0
  37. package/ir/declarations.ts +201 -0
  38. package/ir/diagnostics.ts +43 -0
  39. package/ir/domains.ts +96 -0
  40. package/ir/index.ts +7 -0
  41. package/ir/link.ts +176 -0
  42. package/ir/model.ts +268 -0
  43. package/ir/normalization-error.ts +10 -0
  44. package/ir/routine.ts +826 -0
  45. package/ir/scalars.ts +675 -0
  46. package/ir/types.ts +70 -0
  47. package/package.json +105 -4
  48. package/project/artifacts.ts +277 -0
  49. package/project/cli.ts +282 -0
  50. package/project/index.ts +1 -0
  51. package/project/project.ts +298 -0
  52. package/project/server.ts +83 -0
  53. package/src/frontend/async.ts +1060 -0
  54. package/src/frontend/blocks.ts +419 -0
  55. package/src/frontend/configuration.ts +167 -0
  56. package/src/frontend/const-functions.ts +153 -0
  57. package/src/frontend/cooperative.ts +75 -0
  58. package/src/frontend/elaborate/capture.ts +138 -0
  59. package/src/frontend/elaborate/graph.ts +94 -0
  60. package/src/frontend/elaborate/index.ts +302 -0
  61. package/src/frontend/elaborate/layouts.ts +390 -0
  62. package/src/frontend/elaborate/model.ts +177 -0
  63. package/src/frontend/elaborate/specialize.ts +2280 -0
  64. package/src/frontend/exposure.ts +147 -0
  65. package/src/frontend/module-graph.ts +63 -0
  66. package/src/frontend/promises.ts +751 -0
  67. package/src/frontend/services.ts +435 -0
  68. package/src/frontend/types.ts +578 -0
  69. package/src/frontend/typescript.ts +2442 -0
  70. package/src/index.ts +7 -0
  71. package/stdlib/clock.ts +2 -0
  72. package/stdlib/math.ts +19 -0
  73. package/types/async.d.ts +81 -0
  74. package/types/clock.d.ts +4 -0
  75. package/types/config.d.ts +2 -0
  76. package/types/data-types.d.ts +31 -0
  77. package/types/globals.d.ts +40 -0
  78. package/types/index.d.ts +111 -0
  79. package/types/plc.d.ts +5 -0
package/ir/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type TypeId = string;
2
+ export type SymbolId = string;
3
+
4
+ export interface Field {
5
+ name: string;
6
+ type: TypeId;
7
+ readonly: boolean;
8
+ mode?: "inOut";
9
+ }
10
+
11
+ export type PlcType =
12
+ | { id: TypeId; kind: "bool" }
13
+ | { id: TypeId; kind: "integer"; bits: 8 | 16 | 32 | 64; signed: boolean }
14
+ | { id: TypeId; kind: "bits"; bits: 8 | 16 | 32 | 64 }
15
+ | { id: TypeId; kind: "float"; bits: 32 | 64 }
16
+ | { id: TypeId; kind: "duration" }
17
+ | { id: TypeId; kind: "calendar"; domain: "date" | "timeOfDay" | "dateTime" }
18
+ | { id: TypeId; kind: "string"; capacity: number; encoding: "utf8" | "utf16" }
19
+ | {
20
+ id: TypeId;
21
+ kind: "array";
22
+ element: TypeId;
23
+ length: number;
24
+ lowerBound: number;
25
+ /** `Record<Enum, T>`: the enum whose contiguous member values are the bounds and the only indexes. */
26
+ index?: TypeId;
27
+ }
28
+ | { id: TypeId; kind: "record"; fields: readonly Field[] }
29
+ | { id: TypeId; kind: "enum"; base: TypeId; members: readonly { name: string; value: string }[] }
30
+ | { id: TypeId; kind: "block"; block: SymbolId; output?: TypeId };
31
+
32
+ /** The block allocated by a type: a block itself, or a fixed array of one. */
33
+ export const instancedBlock = (
34
+ id: TypeId,
35
+ types: ReadonlyMap<TypeId, PlcType>,
36
+ ): Extract<PlcType, { kind: "block" }> | undefined => {
37
+ const type = types.get(id);
38
+ const element = type?.kind === "array" ? types.get(type.element) : type;
39
+ return element?.kind === "block" ? element : undefined;
40
+ };
41
+
42
+ export const primitiveTypes: readonly PlcType[] = [
43
+ { id: "BOOL", kind: "bool" },
44
+ ...(
45
+ [
46
+ ["SINT", "USINT", "BYTE", 8],
47
+ ["INT", "UINT", "WORD", 16],
48
+ ["DINT", "UDINT", "DWORD", 32],
49
+ ["LINT", "ULINT", "LWORD", 64],
50
+ ] as const
51
+ ).flatMap(([signed, unsigned, bitstring, bits]) => [
52
+ { id: signed, kind: "integer" as const, bits, signed: true },
53
+ { id: unsigned, kind: "integer" as const, bits, signed: false },
54
+ { id: bitstring, kind: "bits" as const, bits },
55
+ ]),
56
+ { id: "REAL", kind: "float", bits: 32 },
57
+ { id: "LREAL", kind: "float", bits: 64 },
58
+ { id: "TIME", kind: "duration" },
59
+ { id: "DATE", kind: "calendar", domain: "date" },
60
+ { id: "TIME_OF_DAY", kind: "calendar", domain: "timeOfDay" },
61
+ { id: "DATE_AND_TIME", kind: "calendar", domain: "dateTime" },
62
+ ];
63
+
64
+ export function integerRange(
65
+ type: Extract<PlcType, { kind: "integer" | "bits" }>,
66
+ ): readonly [bigint, bigint] {
67
+ const signed = type.kind === "integer" && type.signed;
68
+ const limit = 1n << BigInt(type.bits - (signed ? 1 : 0));
69
+ return signed ? [-limit, limit - 1n] : [0n, limit - 1n];
70
+ }
package/package.json CHANGED
@@ -1,6 +1,107 @@
1
1
  {
2
2
  "name": "@tsplc/compiler",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "license": "MIT",
4
+ "description": "Write CODESYS PLC programs in TypeScript and compile them to IEC 61131-3 Structured Text. Provides the tsplc CLI.",
5
+ "keywords": [
6
+ "plc",
7
+ "codesys",
8
+ "iec-61131-3",
9
+ "structured-text",
10
+ "typescript",
11
+ "compiler"
12
+ ],
13
+ "homepage": "https://github.com/livingforjesus/tsplc#readme",
14
+ "bugs": "https://github.com/livingforjesus/tsplc/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/livingforjesus/tsplc.git",
18
+ "directory": "packages/compiler"
19
+ },
20
+ "version": "0.1.0",
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./src/index.ts",
25
+ "bun": "./src/index.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./ir": {
29
+ "types": "./ir/index.ts",
30
+ "bun": "./ir/index.ts",
31
+ "default": "./dist/ir.js"
32
+ },
33
+ "./codesys": {
34
+ "types": "./backends/codesys/index.ts",
35
+ "bun": "./backends/codesys/index.ts",
36
+ "default": "./dist/codesys.js"
37
+ },
38
+ "./project": {
39
+ "types": "./project/index.ts",
40
+ "bun": "./project/index.ts",
41
+ "default": "./dist/project.js"
42
+ },
43
+ "./clock": {
44
+ "types": "./types/clock.d.ts",
45
+ "bun": "./stdlib/clock.ts",
46
+ "default": "./dist/clock.js"
47
+ },
48
+ "./math": {
49
+ "types": "./stdlib/math.ts",
50
+ "bun": "./stdlib/math.ts",
51
+ "default": "./dist/math.js"
52
+ },
53
+ "./types": {
54
+ "types": "./types/index.d.ts",
55
+ "default": "./types/index.d.ts"
56
+ },
57
+ "./data-types": {
58
+ "types": "./types/data-types.d.ts",
59
+ "default": "./types/data-types.d.ts"
60
+ },
61
+ "./globals": {
62
+ "types": "./types/globals.d.ts",
63
+ "default": "./types/globals.d.ts"
64
+ },
65
+ "./async": {
66
+ "types": "./types/async.d.ts",
67
+ "default": "./types/async.d.ts"
68
+ },
69
+ "./config": {
70
+ "types": "./types/config.d.ts",
71
+ "default": "./types/config.d.ts"
72
+ },
73
+ "./plc": {
74
+ "types": "./types/plc.d.ts",
75
+ "default": "./types/plc.d.ts"
76
+ }
77
+ },
78
+ "files": [
79
+ "dist",
80
+ "src",
81
+ "ir",
82
+ "backends",
83
+ "project",
84
+ "types",
85
+ "stdlib",
86
+ "!**/*.test.ts",
87
+ "!**/__tests__/**"
88
+ ],
89
+ "engines": {
90
+ "bun": ">=1.4.3"
91
+ },
92
+ "dependencies": {
93
+ "@tsplc/build": "0.1.0",
94
+ "@tsplc/config": "0.1.0",
95
+ "@tsplc/typegen": "0.1.0",
96
+ "typescript": "6.0.3",
97
+ "zod": "4.3.6",
98
+ "@types/bun": "1.4.2",
99
+ "@tsplc/server": "0.1.0"
100
+ },
101
+ "bin": {
102
+ "tsplc": "./dist/cli.js"
103
+ },
104
+ "publishConfig": {
105
+ "access": "public"
106
+ }
107
+ }
@@ -0,0 +1,277 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { buildCodesys } from "@tsplc/compiler/codesys";
5
+
6
+ const missing = (cause: unknown): boolean =>
7
+ cause instanceof Error && "code" in cause && cause.code === "ENOENT";
8
+ const digest = (value: string | Uint8Array): string =>
9
+ createHash("sha256").update(value).digest("hex");
10
+
11
+ // Ownership is an explicit boundary: generated files must match their manifest,
12
+ // and engineering trees must belong to this configuration before vendor writes.
13
+ interface Manifest {
14
+ kind: "tsplc-artifacts";
15
+ version: 1;
16
+ configFile: string;
17
+ files: { path: string; sha256: string }[];
18
+ }
19
+
20
+ function manifest(value: unknown, configFile: string): Manifest {
21
+ if (
22
+ typeof value !== "object" ||
23
+ value === null ||
24
+ !("kind" in value) ||
25
+ value.kind !== "tsplc-artifacts" ||
26
+ !("version" in value) ||
27
+ value.version !== 1 ||
28
+ !("configFile" in value) ||
29
+ value.configFile !== configFile ||
30
+ !("files" in value) ||
31
+ !Array.isArray(value.files)
32
+ ) {
33
+ throw new Error("Output directory has no matching tsPLC ownership manifest.");
34
+ }
35
+
36
+ const files = value.files.map((item: unknown) => {
37
+ if (
38
+ typeof item !== "object" ||
39
+ item === null ||
40
+ !("path" in item) ||
41
+ typeof item.path !== "string" ||
42
+ !/^[A-Za-z0-9_ .-]+(?:\/[A-Za-z0-9_ .-]+)*$/.test(item.path) ||
43
+ item.path.split("/").some((part) => part === "." || part === "..") ||
44
+ ["manifest.json", "receipt.json"].includes(item.path) ||
45
+ !("sha256" in item) ||
46
+ typeof item.sha256 !== "string" ||
47
+ !/^[0-9a-f]{64}$/.test(item.sha256)
48
+ ) {
49
+ throw new Error("Invalid generated file ownership entry.");
50
+ }
51
+ return { path: item.path, sha256: item.sha256 };
52
+ });
53
+ if (new Set(files.map((item) => item.path)).size !== files.length) {
54
+ throw new Error("Duplicate generated file ownership entry.");
55
+ }
56
+ return { kind: "tsplc-artifacts", version: 1, configFile, files };
57
+ }
58
+
59
+ async function directory(path: string): Promise<boolean> {
60
+ try {
61
+ const entry = await lstat(path);
62
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
63
+ throw new Error(`Generated directory must be a real directory: ${path}`);
64
+ }
65
+ return true;
66
+ } catch (cause) {
67
+ if (missing(cause)) {
68
+ return false;
69
+ }
70
+ throw cause;
71
+ }
72
+ }
73
+
74
+ async function filesIn(path: string, prefix = ""): Promise<string[]> {
75
+ const result: string[] = [];
76
+ for (const entry of await readdir(path, { withFileTypes: true })) {
77
+ const name = prefix + entry.name;
78
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
79
+ result.push(...(await filesIn(join(path, entry.name), `${name}/`)));
80
+ } else {
81
+ result.push(name);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export async function engineeringDirectory(path: string, configFile: string): Promise<void> {
88
+ const marker = join(path, "ownership.json");
89
+ const exists = await directory(path);
90
+ if (!exists || !(await readdir(path)).length) {
91
+ await mkdir(path, { recursive: true });
92
+ await writeFile(
93
+ marker,
94
+ `${JSON.stringify({ kind: "tsplc-engineering", version: 1, configFile }, null, 2)}\n`,
95
+ { flag: "wx" },
96
+ );
97
+ } else {
98
+ const entry = await lstat(marker).catch((cause: unknown) => {
99
+ if (missing(cause)) {
100
+ throw new Error(`Engineering cache has no matching tsPLC ownership marker: ${path}`);
101
+ }
102
+ throw cause;
103
+ });
104
+ if (!entry.isFile() || entry.isSymbolicLink()) {
105
+ throw new Error("Engineering ownership marker must be a real file.");
106
+ }
107
+ const value = JSON.parse(await readFile(marker, "utf8"));
108
+ if (
109
+ value?.kind !== "tsplc-engineering" ||
110
+ value.version !== 1 ||
111
+ value.configFile !== configFile
112
+ ) {
113
+ throw new Error(`Engineering cache has no matching tsPLC ownership marker: ${path}`);
114
+ }
115
+ }
116
+
117
+ const inspect = async (directory: string): Promise<void> => {
118
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
119
+ const child = join(directory, entry.name);
120
+ if (entry.isSymbolicLink()) {
121
+ throw new Error(`Engineering cache must not contain symlinks: ${child}`);
122
+ }
123
+ if (entry.isDirectory()) {
124
+ await inspect(child);
125
+ }
126
+ }
127
+ };
128
+ await inspect(path);
129
+ }
130
+
131
+ export async function owned(
132
+ configFile: string,
133
+ withdrawReceipt = false,
134
+ ): Promise<{ root: string; output: string; ownership?: Manifest; unknown: string[] }> {
135
+ const root = join(dirname(configFile), ".tsplc");
136
+ const output = join(root, "artifacts");
137
+ if (!(await directory(root)) || !(await directory(output))) {
138
+ return { root, output, unknown: [] };
139
+ }
140
+
141
+ const manifestPath = join(output, "manifest.json");
142
+ const manifestEntry = await lstat(manifestPath);
143
+ if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink()) {
144
+ throw new Error("Ownership manifest must be a real file.");
145
+ }
146
+ const ownership = manifest(JSON.parse(await readFile(manifestPath, "utf8")), configFile);
147
+ const receipt = join(output, "receipt.json");
148
+ try {
149
+ const entry = await lstat(receipt);
150
+ if (!entry.isFile() || entry.isSymbolicLink()) {
151
+ throw new Error("Build receipt must be a real file.");
152
+ }
153
+ const value = JSON.parse(await readFile(receipt, "utf8"));
154
+ if (
155
+ value?.kind !== "tsplc-build" ||
156
+ value.version !== 1 ||
157
+ value.configFile !== configFile ||
158
+ !["emitted", "vendor-compiled"].includes(value.status)
159
+ ) {
160
+ throw new Error("Build receipt is not owned by this configuration.");
161
+ }
162
+ if (withdrawReceipt) {
163
+ await rm(receipt);
164
+ }
165
+ } catch (cause) {
166
+ if (!missing(cause)) {
167
+ throw cause;
168
+ }
169
+ }
170
+
171
+ const names = new Set([
172
+ ...ownership.files.map((file) => file.path),
173
+ "manifest.json",
174
+ "receipt.json",
175
+ ]);
176
+ for (const file of ownership.files) {
177
+ let parent = output;
178
+ for (const part of file.path.split("/").slice(0, -1)) {
179
+ parent = join(parent, part);
180
+ if (!(await directory(parent))) {
181
+ throw new Error(`Missing generated directory: ${parent}`);
182
+ }
183
+ }
184
+ const path = join(output, file.path);
185
+ const entry = await lstat(path);
186
+ if (!entry.isFile() || entry.isSymbolicLink() || digest(await readFile(path)) !== file.sha256) {
187
+ throw new Error(
188
+ `Generated file was replaced or edited; preserve it before rebuilding/cleaning: ${path}`,
189
+ );
190
+ }
191
+ }
192
+ return {
193
+ root,
194
+ output,
195
+ ownership,
196
+ unknown: (await filesIn(output)).filter((name) => !names.has(name)),
197
+ };
198
+ }
199
+
200
+ interface Receipt {
201
+ configFile: string;
202
+ status: "emitted" | "vendor-compiled";
203
+ vendor?: Pick<Awaited<ReturnType<typeof buildCodesys>>, "application" | "tasks">;
204
+ }
205
+
206
+ // Compile in a separate staging tree. Recheck ownership immediately before the
207
+ // swap, because the vendor build may have given the user time to edit old files.
208
+ export async function publishArtifacts(
209
+ staging: string,
210
+ previous: Awaited<ReturnType<typeof owned>>,
211
+ receipt: Receipt,
212
+ ): Promise<void> {
213
+ const configFile = receipt.configFile;
214
+ const generated = (await filesIn(staging)).sort();
215
+ const ownership: Manifest = { kind: "tsplc-artifacts", version: 1, configFile, files: [] };
216
+ for (const path of generated) {
217
+ ownership.files.push({ path, sha256: digest(await readFile(join(staging, path))) });
218
+ }
219
+ await writeFile(join(staging, "manifest.json"), `${JSON.stringify(ownership, null, 2)}\n`);
220
+ await writeFile(
221
+ join(staging, "receipt.json"),
222
+ `${JSON.stringify(
223
+ { kind: "tsplc-build", version: 1, generatedAt: new Date().toISOString(), ...receipt },
224
+ null,
225
+ 2,
226
+ )}\n`,
227
+ );
228
+
229
+ const current = await owned(configFile);
230
+ if (JSON.stringify(current.ownership) !== JSON.stringify(previous.ownership)) {
231
+ throw new Error("Output ownership changed during the build; preserve it before rebuilding.");
232
+ }
233
+ if (current.unknown.length) {
234
+ throw new Error(`Output contains unrelated files: ${current.unknown.join(", ")}`);
235
+ }
236
+
237
+ const backup = `${staging}-previous`;
238
+ if (previous.ownership) {
239
+ await rename(previous.output, backup);
240
+ }
241
+ try {
242
+ await rename(staging, previous.output);
243
+ } catch (cause) {
244
+ if (previous.ownership) {
245
+ await rename(backup, previous.output);
246
+ }
247
+ throw cause;
248
+ }
249
+ if (previous.ownership) {
250
+ await rm(backup, { recursive: true });
251
+ }
252
+ }
253
+
254
+ export async function cleanArtifacts(configFile: string): Promise<void> {
255
+ const generated = await owned(configFile);
256
+ if (!generated.ownership) {
257
+ return;
258
+ }
259
+ for (const entry of generated.ownership.files) {
260
+ await rm(join(generated.output, entry.path));
261
+ }
262
+ await rm(join(generated.output, "receipt.json"), { force: true });
263
+ await rm(join(generated.output, "manifest.json"));
264
+
265
+ // Remove empty generated directories while preserving unrelated files.
266
+ const removeEmpty = async (path: string): Promise<void> => {
267
+ for (const entry of await readdir(path, { withFileTypes: true })) {
268
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
269
+ await removeEmpty(join(path, entry.name));
270
+ }
271
+ }
272
+ if (!(await readdir(path)).length) {
273
+ await rm(path, { recursive: true });
274
+ }
275
+ };
276
+ await removeEmpty(generated.output);
277
+ }