@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.
- package/LICENSE +21 -0
- package/README.md +16 -2
- package/backends/codesys/config.ts +54 -0
- package/backends/codesys/emit.ts +1063 -0
- package/backends/codesys/engineering.ts +88 -0
- package/backends/codesys/http.ts +598 -0
- package/backends/codesys/index.ts +12 -0
- package/backends/codesys/io-paths.ts +103 -0
- package/backends/codesys/names.ts +76 -0
- package/backends/codesys/numeric.ts +356 -0
- package/backends/codesys/plan.ts +512 -0
- package/backends/codesys/publication.ts +59 -0
- package/backends/codesys/storage.ts +63 -0
- package/backends/codesys/task-access.ts +118 -0
- package/backends/codesys/types.ts +178 -0
- package/backends/codesys/vendor.ts +108 -0
- package/dist/cli.js +740 -0
- package/dist/cli.js.map +13 -0
- package/dist/clock.js +8 -0
- package/dist/clock.js.map +10 -0
- package/dist/codesys.js +2587 -0
- package/dist/codesys.js.map +22 -0
- package/dist/elaborate-capture.js +118 -0
- package/dist/elaborate-capture.js.map +10 -0
- package/dist/index.js +6859 -0
- package/dist/index.js.map +25 -0
- package/dist/ir.js +1888 -0
- package/dist/ir.js.map +22 -0
- package/dist/math.js +24 -0
- package/dist/math.js.map +10 -0
- package/dist/project.js +423 -0
- package/dist/project.js.map +11 -0
- package/ir/aggregates.ts +333 -0
- package/ir/allocation.ts +74 -0
- package/ir/core-model.ts +116 -0
- package/ir/core.ts +63 -0
- package/ir/declarations.ts +201 -0
- package/ir/diagnostics.ts +43 -0
- package/ir/domains.ts +96 -0
- package/ir/index.ts +7 -0
- package/ir/link.ts +176 -0
- package/ir/model.ts +268 -0
- package/ir/normalization-error.ts +10 -0
- package/ir/routine.ts +826 -0
- package/ir/scalars.ts +675 -0
- package/ir/types.ts +70 -0
- package/package.json +105 -4
- package/project/artifacts.ts +277 -0
- package/project/cli.ts +282 -0
- package/project/index.ts +1 -0
- package/project/project.ts +298 -0
- package/project/server.ts +83 -0
- package/src/frontend/async.ts +1060 -0
- package/src/frontend/blocks.ts +419 -0
- package/src/frontend/configuration.ts +167 -0
- package/src/frontend/const-functions.ts +153 -0
- package/src/frontend/cooperative.ts +75 -0
- package/src/frontend/elaborate/capture.ts +138 -0
- package/src/frontend/elaborate/graph.ts +94 -0
- package/src/frontend/elaborate/index.ts +302 -0
- package/src/frontend/elaborate/layouts.ts +390 -0
- package/src/frontend/elaborate/model.ts +177 -0
- package/src/frontend/elaborate/specialize.ts +2280 -0
- package/src/frontend/exposure.ts +147 -0
- package/src/frontend/module-graph.ts +63 -0
- package/src/frontend/promises.ts +751 -0
- package/src/frontend/services.ts +435 -0
- package/src/frontend/types.ts +578 -0
- package/src/frontend/typescript.ts +2442 -0
- package/src/index.ts +7 -0
- package/stdlib/clock.ts +2 -0
- package/stdlib/math.ts +19 -0
- package/types/async.d.ts +81 -0
- package/types/clock.d.ts +4 -0
- package/types/config.d.ts +2 -0
- package/types/data-types.d.ts +31 -0
- package/types/globals.d.ts +40 -0
- package/types/index.d.ts +111 -0
- package/types/plc.d.ts +5 -0
package/project/cli.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { buildServer } from "./server.ts";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
buildTargetFor,
|
|
7
|
+
controllerFor,
|
|
8
|
+
loadTsplcConfig,
|
|
9
|
+
rawBuildConfig,
|
|
10
|
+
simulationConfig,
|
|
11
|
+
} from "@tsplc/config";
|
|
12
|
+
import {
|
|
13
|
+
buildProject as buildSt,
|
|
14
|
+
configureDocker,
|
|
15
|
+
configureRuntime,
|
|
16
|
+
inspectController,
|
|
17
|
+
installLibraries,
|
|
18
|
+
loadSources,
|
|
19
|
+
resolveConfig,
|
|
20
|
+
runProject,
|
|
21
|
+
updateController,
|
|
22
|
+
} from "@tsplc/build";
|
|
23
|
+
import { checkHostConfig } from "@tsplc/compiler";
|
|
24
|
+
import { type Diagnostic, formatDiagnostic, hasErrors } from "@tsplc/compiler/ir";
|
|
25
|
+
import { deviceTree } from "@tsplc/typegen";
|
|
26
|
+
import { loadHardware, resolveIo } from "@tsplc/typegen/hardware";
|
|
27
|
+
import { generateProject } from "@tsplc/typegen";
|
|
28
|
+
import { buildProject, checkProject, cleanProject, projectTargets } from "./project.ts";
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
options: {
|
|
34
|
+
config: { type: "string" },
|
|
35
|
+
help: { type: "boolean", short: "h" },
|
|
36
|
+
"emit-only": { type: "boolean" },
|
|
37
|
+
format: { type: "string" },
|
|
38
|
+
read: { type: "string", multiple: true },
|
|
39
|
+
"initialize-user": { type: "boolean" },
|
|
40
|
+
"frozen-lockfile": { type: "boolean" },
|
|
41
|
+
check: { type: "boolean" },
|
|
42
|
+
plc: { type: "boolean" },
|
|
43
|
+
server: { type: "boolean" },
|
|
44
|
+
simulate: { type: "boolean" },
|
|
45
|
+
runtime: { type: "boolean" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const command = positionals[0],
|
|
49
|
+
file = values.config ?? "tsplc.config.ts";
|
|
50
|
+
if (!command || values.help) {
|
|
51
|
+
console.log(
|
|
52
|
+
"tsplc install|typegen|check|build|run|download|online-change|inspect|clean|configure [--config tsplc.config.ts] [--plc|--server] [--simulate] [--runtime] [--emit-only] [--format json] [--read symbol]",
|
|
53
|
+
);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (positionals.length > 1) throw new Error("Specify one command.");
|
|
57
|
+
if (
|
|
58
|
+
![
|
|
59
|
+
"install",
|
|
60
|
+
"typegen",
|
|
61
|
+
"check",
|
|
62
|
+
"build",
|
|
63
|
+
"run",
|
|
64
|
+
"download",
|
|
65
|
+
"online-change",
|
|
66
|
+
"inspect",
|
|
67
|
+
"clean",
|
|
68
|
+
"configure",
|
|
69
|
+
].includes(command)
|
|
70
|
+
)
|
|
71
|
+
throw new Error(`Unknown command ${command}.`);
|
|
72
|
+
if (values.format && !["json", "text"].includes(values.format))
|
|
73
|
+
throw new Error("--format must be json or text.");
|
|
74
|
+
if (values["emit-only"] && command !== "build")
|
|
75
|
+
throw new Error("--emit-only is supported by build only.");
|
|
76
|
+
if (values.runtime && command !== "configure")
|
|
77
|
+
throw new Error("--runtime applies only to configure.");
|
|
78
|
+
if ((values.plc || values.server) && command !== "build")
|
|
79
|
+
throw new Error("--plc and --server apply only to build.");
|
|
80
|
+
if (values.simulate && !["run", "download", "online-change"].includes(command))
|
|
81
|
+
throw new Error("--simulate applies only to run, download and online-change.");
|
|
82
|
+
if (values.plc && values.server)
|
|
83
|
+
throw new Error("Choose --plc or --server, or omit both for a full build.");
|
|
84
|
+
if (values.server && values["emit-only"])
|
|
85
|
+
throw new Error("--emit-only applies to PLC emission only.");
|
|
86
|
+
const log = console.log,
|
|
87
|
+
previousLogTarget = process.env["TSPLC_LOG_STDERR"];
|
|
88
|
+
if (values.format === "json") {
|
|
89
|
+
console.log = console.error;
|
|
90
|
+
process.env["TSPLC_LOG_STDERR"] = "1";
|
|
91
|
+
}
|
|
92
|
+
let result: unknown;
|
|
93
|
+
try {
|
|
94
|
+
if (command === "configure") {
|
|
95
|
+
await (values.runtime ? configureRuntime() : configureDocker());
|
|
96
|
+
result = { ok: true };
|
|
97
|
+
} else if (command === "clean") {
|
|
98
|
+
result = await cleanProject(file);
|
|
99
|
+
} else {
|
|
100
|
+
if (["check", "build", "run", "download", "online-change"].includes(command)) {
|
|
101
|
+
const diagnostics = checkHostConfig(resolve(file));
|
|
102
|
+
if (hasErrors(diagnostics)) throw new DiagnosticFailure({ diagnostics });
|
|
103
|
+
}
|
|
104
|
+
const loaded = await loadTsplcConfig(file);
|
|
105
|
+
const { config, configFile, root } = loaded;
|
|
106
|
+
// Generated types keep describing the hardware project; only the download omits fieldbus I/O.
|
|
107
|
+
const target = values.simulate ? { ...loaded, config: simulationConfig(config) } : loaded;
|
|
108
|
+
if (command === "install")
|
|
109
|
+
result = {
|
|
110
|
+
libraries: await installLibraries(config.dependencies, root, {
|
|
111
|
+
frozen: values["frozen-lockfile"],
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
else if (command === "typegen") {
|
|
115
|
+
const targets = await projectTargets(file, { loaded });
|
|
116
|
+
if (config.compiler && !values.check) {
|
|
117
|
+
await generateProject(file, { hardwareOnly: true, loaded, targets });
|
|
118
|
+
const emitted = await buildProject(file, { emitOnly: true, loaded });
|
|
119
|
+
if (hasErrors(emitted.diagnostics)) throw new DiagnosticFailure(emitted);
|
|
120
|
+
}
|
|
121
|
+
result = await generateProject(file, { check: values.check, loaded, targets });
|
|
122
|
+
} else if (command === "check") {
|
|
123
|
+
result = config.compiler
|
|
124
|
+
? await checkProject(file, { loaded })
|
|
125
|
+
: {
|
|
126
|
+
objects: (
|
|
127
|
+
await loadSources(
|
|
128
|
+
resolveConfig(
|
|
129
|
+
{
|
|
130
|
+
...rawBuildConfig(config),
|
|
131
|
+
io: resolveIo(config, await loadHardware(config, root)),
|
|
132
|
+
},
|
|
133
|
+
configFile,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
).objects.length,
|
|
137
|
+
};
|
|
138
|
+
} else if (command === "build" && values.server) {
|
|
139
|
+
result = await buildServer(config, root);
|
|
140
|
+
} else if (command === "build" || command === "run") {
|
|
141
|
+
if (config.compiler) {
|
|
142
|
+
const built = await buildProject(file, {
|
|
143
|
+
emitOnly: values["emit-only"],
|
|
144
|
+
frozenLockfile: values["frozen-lockfile"],
|
|
145
|
+
loaded: target,
|
|
146
|
+
controller: controllerFor(config, command),
|
|
147
|
+
buildTarget: buildTargetFor(command),
|
|
148
|
+
});
|
|
149
|
+
if (hasErrors(built.diagnostics)) throw new DiagnosticFailure(built);
|
|
150
|
+
await generateProject(file, {
|
|
151
|
+
loaded,
|
|
152
|
+
targets: await projectTargets(file, { loaded }),
|
|
153
|
+
});
|
|
154
|
+
result = built;
|
|
155
|
+
} else {
|
|
156
|
+
if (values["emit-only"]) throw new Error("Raw Structured Text is already emitted.");
|
|
157
|
+
if (!config.build) throw new Error("Configure build settings.");
|
|
158
|
+
const build = rawBuildConfig(config);
|
|
159
|
+
const libraries = await installLibraries(config.dependencies, root, {
|
|
160
|
+
frozen: values["frozen-lockfile"],
|
|
161
|
+
});
|
|
162
|
+
const hardware = await loadHardware(target.config, root);
|
|
163
|
+
result = await buildSt(
|
|
164
|
+
resolveConfig(
|
|
165
|
+
{
|
|
166
|
+
...build,
|
|
167
|
+
libraries,
|
|
168
|
+
io: resolveIo(target.config, hardware),
|
|
169
|
+
deviceTree: await deviceTree(target.config, hardware),
|
|
170
|
+
controller: controllerFor(config, command),
|
|
171
|
+
},
|
|
172
|
+
configFile,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
await generateProject(file, { hardwareOnly: !config.typegen, loaded });
|
|
176
|
+
}
|
|
177
|
+
if (command === "build" && !values.plc && !values["emit-only"] && config.server?.runtime) {
|
|
178
|
+
const backend = await buildServer(config, root);
|
|
179
|
+
result = { ...(typeof result === "object" && result !== null ? result : {}), ...backend };
|
|
180
|
+
}
|
|
181
|
+
if (command === "run") {
|
|
182
|
+
if (!config.build) throw new Error("Configure build to run this project.");
|
|
183
|
+
await runProject(
|
|
184
|
+
resolveConfig(config.compiler ? config.build : rawBuildConfig(config), configFile),
|
|
185
|
+
config.name,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
} else if (command === "inspect") {
|
|
189
|
+
if (!config.build) throw new Error("Configure build settings and controller connection.");
|
|
190
|
+
// Inspection needs only the connection/baseline. It must remain usable
|
|
191
|
+
// when local sources, descriptors or dependency caches are unavailable.
|
|
192
|
+
result = await inspectController(
|
|
193
|
+
resolveConfig(
|
|
194
|
+
{ ...config.build, controller: controllerFor(config, command) },
|
|
195
|
+
configFile,
|
|
196
|
+
),
|
|
197
|
+
values.read,
|
|
198
|
+
);
|
|
199
|
+
} else if (["download", "online-change"].includes(command)) {
|
|
200
|
+
if (!config.build) throw new Error("Configure build settings and controller connection.");
|
|
201
|
+
let settings: ReturnType<typeof resolveConfig>;
|
|
202
|
+
if (config.compiler) {
|
|
203
|
+
const emitted = await buildProject(file, {
|
|
204
|
+
emitOnly: true,
|
|
205
|
+
loaded: target,
|
|
206
|
+
buildTarget: buildTargetFor(command),
|
|
207
|
+
});
|
|
208
|
+
if (hasErrors(emitted.diagnostics)) throw new DiagnosticFailure(emitted);
|
|
209
|
+
if (!emitted.controllerConfig)
|
|
210
|
+
throw new Error("Configure build settings for deployment.");
|
|
211
|
+
// Emission already resolves the generated entry/task paths. Passing
|
|
212
|
+
// this through the input schema again would reject its configFile.
|
|
213
|
+
settings = { ...emitted.controllerConfig, io: {} };
|
|
214
|
+
} else
|
|
215
|
+
settings = resolveConfig(
|
|
216
|
+
{
|
|
217
|
+
...rawBuildConfig(config),
|
|
218
|
+
io: resolveIo(target.config, await loadHardware(target.config, root)),
|
|
219
|
+
},
|
|
220
|
+
configFile,
|
|
221
|
+
);
|
|
222
|
+
const libraries = await installLibraries(config.dependencies, root, {
|
|
223
|
+
frozen: values["frozen-lockfile"],
|
|
224
|
+
});
|
|
225
|
+
result = await updateController(
|
|
226
|
+
{ ...settings, libraries, controller: controllerFor(config, command) },
|
|
227
|
+
command as "download" | "online-change",
|
|
228
|
+
{
|
|
229
|
+
initializeUser: values["initialize-user"],
|
|
230
|
+
read: values.read,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
} else throw new Error(`Unknown command ${command}.`);
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
console.log = log;
|
|
237
|
+
if (previousLogTarget === undefined) delete process.env["TSPLC_LOG_STDERR"];
|
|
238
|
+
else process.env["TSPLC_LOG_STDERR"] = previousLogTarget;
|
|
239
|
+
}
|
|
240
|
+
return report(result, values.format);
|
|
241
|
+
}
|
|
242
|
+
class DiagnosticFailure extends Error {
|
|
243
|
+
constructor(readonly result: unknown) {
|
|
244
|
+
super("PLC validation failed.");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function report(result: unknown, format?: string) {
|
|
248
|
+
const diagnostics =
|
|
249
|
+
result && typeof result === "object" && "diagnostics" in result
|
|
250
|
+
? (result.diagnostics as Diagnostic[])
|
|
251
|
+
: [];
|
|
252
|
+
const ok = !hasErrors(diagnostics);
|
|
253
|
+
if (format === "json")
|
|
254
|
+
console.log(
|
|
255
|
+
JSON.stringify({ ok, ...(result && typeof result === "object" ? result : { result }) }),
|
|
256
|
+
);
|
|
257
|
+
else {
|
|
258
|
+
for (const diagnostic of diagnostics) console.error(formatDiagnostic(diagnostic));
|
|
259
|
+
if (ok) console.log("tsPLC completed.");
|
|
260
|
+
}
|
|
261
|
+
return ok ? 0 : 1;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
process.exitCode = await main();
|
|
265
|
+
} catch (cause) {
|
|
266
|
+
// main() may fail before its options parse, so detect JSON output from raw argv.
|
|
267
|
+
const json = process.argv.includes("json") || process.argv.includes("--format=json");
|
|
268
|
+
if (cause instanceof DiagnosticFailure) {
|
|
269
|
+
process.exitCode = report(cause.result, json ? "json" : undefined);
|
|
270
|
+
} else {
|
|
271
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
272
|
+
if (json)
|
|
273
|
+
console.log(
|
|
274
|
+
JSON.stringify({
|
|
275
|
+
ok: false,
|
|
276
|
+
diagnostics: [{ origin: "config", code: "PLC5003", severity: "error", message }],
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
else console.error(message);
|
|
280
|
+
process.exitCode = 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
package/project/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { checkProject, buildProject, cleanProject, projectTargets } from "./project.ts";
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
buildCodesys,
|
|
5
|
+
codesysBuildConfig,
|
|
6
|
+
type CodesysPlan,
|
|
7
|
+
diagnoseCodesys,
|
|
8
|
+
emitCodesys,
|
|
9
|
+
ioTargets,
|
|
10
|
+
publicationSelectors,
|
|
11
|
+
planCodesys,
|
|
12
|
+
} from "@tsplc/compiler/codesys";
|
|
13
|
+
import { analyze, checkHostConfig } from "@tsplc/compiler";
|
|
14
|
+
import {
|
|
15
|
+
type Controller,
|
|
16
|
+
type BuildTarget,
|
|
17
|
+
type NormalizedConfig,
|
|
18
|
+
type ResolvedTsplcConfig,
|
|
19
|
+
compilerConfig,
|
|
20
|
+
loadTsplcConfig,
|
|
21
|
+
} from "@tsplc/config";
|
|
22
|
+
import { deviceTree, type ProjectTargets, loadHardware, resolveIo } from "@tsplc/typegen";
|
|
23
|
+
import { installLibraries } from "@tsplc/build";
|
|
24
|
+
import { type Diagnostic, hasErrors, normalizeModule } from "@tsplc/compiler/ir";
|
|
25
|
+
import { cleanArtifacts, engineeringDirectory, owned, publishArtifacts } from "./artifacts.ts";
|
|
26
|
+
|
|
27
|
+
const failure = (origin: Diagnostic["origin"], code: string, cause: unknown): Diagnostic => ({
|
|
28
|
+
origin,
|
|
29
|
+
code,
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
type LoadedConfig = Awaited<ReturnType<typeof loadTsplcConfig>>;
|
|
35
|
+
|
|
36
|
+
async function loadProject(
|
|
37
|
+
file: string,
|
|
38
|
+
loaded?: LoadedConfig,
|
|
39
|
+
buildTarget?: BuildTarget,
|
|
40
|
+
): Promise<{
|
|
41
|
+
config?: NormalizedConfig;
|
|
42
|
+
configFile: string;
|
|
43
|
+
shared?: ResolvedTsplcConfig;
|
|
44
|
+
diagnostics: readonly Diagnostic[];
|
|
45
|
+
}> {
|
|
46
|
+
let configFile = resolve(file);
|
|
47
|
+
try {
|
|
48
|
+
configFile = await realpath(configFile);
|
|
49
|
+
const diagnostics = checkHostConfig(configFile);
|
|
50
|
+
if (hasErrors(diagnostics)) return { configFile, diagnostics };
|
|
51
|
+
const shared = (loaded ?? (await loadTsplcConfig(configFile))).config;
|
|
52
|
+
const root = dirname(configFile);
|
|
53
|
+
|
|
54
|
+
const config = compilerConfig(
|
|
55
|
+
shared,
|
|
56
|
+
resolveIo(shared, await loadHardware(shared, root)),
|
|
57
|
+
buildTarget,
|
|
58
|
+
);
|
|
59
|
+
return {
|
|
60
|
+
configFile,
|
|
61
|
+
shared,
|
|
62
|
+
config: { ...config, ...sourceInput(config, root) },
|
|
63
|
+
diagnostics,
|
|
64
|
+
};
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
return { configFile, diagnostics: [failure("config", "PLC5003", cause)] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The analyzer's entry points, resolved against the config file's directory. */
|
|
71
|
+
function sourceInput(
|
|
72
|
+
compiler: { tsconfig: string; programs: Record<string, { module: string; export: string }> },
|
|
73
|
+
root: string,
|
|
74
|
+
) {
|
|
75
|
+
return {
|
|
76
|
+
tsconfig: resolve(root, compiler.tsconfig),
|
|
77
|
+
programs: Object.fromEntries(
|
|
78
|
+
Object.entries(compiler.programs).map(([name, entry]) => [
|
|
79
|
+
name,
|
|
80
|
+
{ ...entry, module: resolve(root, entry.module) },
|
|
81
|
+
]),
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* I/O keys and publication selectors from analyzed PLC source. It ignores the
|
|
88
|
+
* config's own type errors, so a typo cannot switch off the types that flag it;
|
|
89
|
+
* it is undefined only while the source itself fails analysis.
|
|
90
|
+
*/
|
|
91
|
+
export async function projectTargets(
|
|
92
|
+
file = "tsplc.config.ts",
|
|
93
|
+
options: { loaded?: LoadedConfig } = {},
|
|
94
|
+
): Promise<ProjectTargets | undefined> {
|
|
95
|
+
const configFile = await realpath(resolve(file));
|
|
96
|
+
const { config } = options.loaded ?? (await loadTsplcConfig(configFile));
|
|
97
|
+
if (!config.compiler) return undefined;
|
|
98
|
+
const source = analyze({
|
|
99
|
+
...sourceInput(config.compiler, dirname(configFile)),
|
|
100
|
+
services: config.server,
|
|
101
|
+
});
|
|
102
|
+
const core = source.module && normalizeModule(source.module).core;
|
|
103
|
+
return core ? { io: ioTargets(core), publication: publicationSelectors(core) } : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function checkProject(
|
|
107
|
+
file = "tsplc.config.ts",
|
|
108
|
+
options: { loaded?: LoadedConfig; buildTarget?: BuildTarget } = {},
|
|
109
|
+
): Promise<{
|
|
110
|
+
configFile: string;
|
|
111
|
+
config?: NormalizedConfig;
|
|
112
|
+
shared?: ResolvedTsplcConfig;
|
|
113
|
+
plan?: CodesysPlan;
|
|
114
|
+
diagnostics: readonly Diagnostic[];
|
|
115
|
+
}> {
|
|
116
|
+
const loaded = await loadProject(file, options.loaded, options.buildTarget);
|
|
117
|
+
if (!loaded.config) {
|
|
118
|
+
return loaded;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const config = loaded.config;
|
|
122
|
+
if (config.target.kind !== "codesys") {
|
|
123
|
+
return {
|
|
124
|
+
...loaded,
|
|
125
|
+
diagnostics: [failure("backend", "PLC7001", `Unsupported target: ${config.target.kind}`)],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const source = analyze({
|
|
130
|
+
tsconfig: config.tsconfig,
|
|
131
|
+
programs: config.programs,
|
|
132
|
+
services: loaded.shared?.server,
|
|
133
|
+
});
|
|
134
|
+
if (!source.module) {
|
|
135
|
+
return { ...loaded, diagnostics: source.diagnostics };
|
|
136
|
+
}
|
|
137
|
+
const normalized = normalizeModule(source.module);
|
|
138
|
+
if (!normalized.core) {
|
|
139
|
+
return { ...loaded, diagnostics: normalized.diagnostics };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tasks = config.tasks.map((task) => ({
|
|
143
|
+
name: task.name,
|
|
144
|
+
intervalNanoseconds: task.schedule.interval.nanoseconds,
|
|
145
|
+
priority: task.priority,
|
|
146
|
+
programs: task.programs,
|
|
147
|
+
}));
|
|
148
|
+
const planned = planCodesys(normalized.core, { tasks }, config.target.options);
|
|
149
|
+
return {
|
|
150
|
+
...loaded,
|
|
151
|
+
...planned,
|
|
152
|
+
diagnostics: [
|
|
153
|
+
...loaded.diagnostics,
|
|
154
|
+
...source.diagnostics,
|
|
155
|
+
...normalized.diagnostics,
|
|
156
|
+
...planned.diagnostics,
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function buildProject(
|
|
162
|
+
file = "tsplc.config.ts",
|
|
163
|
+
options: {
|
|
164
|
+
emitOnly?: boolean;
|
|
165
|
+
frozenLockfile?: boolean;
|
|
166
|
+
loaded?: LoadedConfig;
|
|
167
|
+
controller?: Controller;
|
|
168
|
+
buildTarget?: BuildTarget;
|
|
169
|
+
} = {},
|
|
170
|
+
): Promise<{
|
|
171
|
+
directory?: string;
|
|
172
|
+
status?: "emitted" | "vendor-compiled";
|
|
173
|
+
controllerConfig?: ReturnType<typeof codesysBuildConfig>;
|
|
174
|
+
diagnostics: readonly Diagnostic[];
|
|
175
|
+
}> {
|
|
176
|
+
let staging: string | undefined;
|
|
177
|
+
try {
|
|
178
|
+
const configFile = await realpath(resolve(file));
|
|
179
|
+
// Withdraw the previous success receipt before checking the new source.
|
|
180
|
+
// Old artifacts remain owned and intact until publication succeeds.
|
|
181
|
+
const previous = await owned(configFile, true);
|
|
182
|
+
if (previous.unknown.length) {
|
|
183
|
+
throw new Error(`Output contains unrelated files: ${previous.unknown.join(", ")}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const checked = await checkProject(configFile, {
|
|
187
|
+
loaded: options.loaded,
|
|
188
|
+
buildTarget: options.buildTarget,
|
|
189
|
+
});
|
|
190
|
+
if (!checked.plan || !checked.config) {
|
|
191
|
+
return { diagnostics: checked.diagnostics };
|
|
192
|
+
}
|
|
193
|
+
const bundle = emitCodesys(checked.plan);
|
|
194
|
+
if (hasErrors(bundle.diagnostics)) {
|
|
195
|
+
return { diagnostics: bundle.diagnostics };
|
|
196
|
+
}
|
|
197
|
+
const controllerConfig = checked.shared?.build
|
|
198
|
+
? codesysBuildConfig(checked.plan, bundle, {
|
|
199
|
+
configFile,
|
|
200
|
+
sourceDir: join(previous.output, "st"),
|
|
201
|
+
outDir: resolve(dirname(configFile), checked.shared.build.outDir),
|
|
202
|
+
controller: options.controller,
|
|
203
|
+
})
|
|
204
|
+
: undefined;
|
|
205
|
+
|
|
206
|
+
await mkdir(previous.root, { recursive: true });
|
|
207
|
+
staging = await mkdtemp(join(previous.root, "stage-"));
|
|
208
|
+
const artifacts = [
|
|
209
|
+
...bundle.artifacts,
|
|
210
|
+
{ path: "mappings.json", content: `${JSON.stringify(bundle.mappings, null, 2)}\n` },
|
|
211
|
+
{ path: "io-symbols.json", content: `${JSON.stringify(bundle.ioSymbols, null, 2)}\n` },
|
|
212
|
+
{ path: "publication.json", content: `${JSON.stringify(bundle.publication, null, 2)}\n` },
|
|
213
|
+
{ path: "config.json", content: `${JSON.stringify(checked.config, null, 2)}\n` },
|
|
214
|
+
];
|
|
215
|
+
for (const artifact of artifacts) {
|
|
216
|
+
const path = join(staging, "st", artifact.path);
|
|
217
|
+
await mkdir(dirname(path), { recursive: true });
|
|
218
|
+
await writeFile(path, artifact.content);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let vendor: Awaited<ReturnType<typeof buildCodesys>> | undefined;
|
|
222
|
+
if (!options.emitOnly) {
|
|
223
|
+
// The engineering cache has its own ownership marker and survives clean.
|
|
224
|
+
const engineering = checked.shared?.build
|
|
225
|
+
? resolve(dirname(configFile), checked.shared.build.outDir ?? "build")
|
|
226
|
+
: join(previous.root, "engineering");
|
|
227
|
+
const libraries = checked.shared
|
|
228
|
+
? await installLibraries(checked.shared.dependencies, dirname(configFile), {
|
|
229
|
+
frozen: options.frozenLockfile,
|
|
230
|
+
})
|
|
231
|
+
: undefined;
|
|
232
|
+
await engineeringDirectory(engineering, configFile);
|
|
233
|
+
const tree =
|
|
234
|
+
checked.shared &&
|
|
235
|
+
(await deviceTree(checked.shared, await loadHardware(checked.shared, dirname(configFile))));
|
|
236
|
+
try {
|
|
237
|
+
vendor = await buildCodesys(checked.plan, bundle, {
|
|
238
|
+
configFile,
|
|
239
|
+
sourceDir: join(staging, "st"),
|
|
240
|
+
outDir: engineering,
|
|
241
|
+
libraries,
|
|
242
|
+
controller: options.controller,
|
|
243
|
+
// The emitted names locate mapped IO; the tree maps their channels.
|
|
244
|
+
deviceTree: tree && {
|
|
245
|
+
...tree,
|
|
246
|
+
mappings: bundle.ioMappings.map(({ address, variable }) => ({ address, variable })),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
} catch (cause) {
|
|
250
|
+
return { diagnostics: diagnoseCodesys(cause, bundle) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const vendorArtifacts = [
|
|
254
|
+
["Application.project", vendor.project],
|
|
255
|
+
["Application.app", vendor.output],
|
|
256
|
+
["Application.crc", vendor.checksum],
|
|
257
|
+
] as const;
|
|
258
|
+
for (const [name, path] of vendorArtifacts) {
|
|
259
|
+
if (!path) continue;
|
|
260
|
+
const destination = join(staging, "vendor", name);
|
|
261
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
262
|
+
await copyFile(path, destination);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const status = vendor ? "vendor-compiled" : "emitted";
|
|
267
|
+
await publishArtifacts(staging, previous, {
|
|
268
|
+
configFile,
|
|
269
|
+
status,
|
|
270
|
+
...(vendor && { vendor: { application: vendor.application, tasks: vendor.tasks } }),
|
|
271
|
+
});
|
|
272
|
+
// Publication moved this directory into place; it is no longer staging.
|
|
273
|
+
staging = undefined;
|
|
274
|
+
return {
|
|
275
|
+
directory: previous.output,
|
|
276
|
+
status,
|
|
277
|
+
...(controllerConfig && { controllerConfig }),
|
|
278
|
+
diagnostics: [...checked.diagnostics, ...bundle.diagnostics],
|
|
279
|
+
};
|
|
280
|
+
} catch (cause) {
|
|
281
|
+
return { diagnostics: [failure("config", "PLC5004", cause)] };
|
|
282
|
+
} finally {
|
|
283
|
+
if (staging) {
|
|
284
|
+
await rm(staging, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function cleanProject(file = "tsplc.config.ts"): Promise<{
|
|
290
|
+
diagnostics: readonly Diagnostic[];
|
|
291
|
+
}> {
|
|
292
|
+
try {
|
|
293
|
+
await cleanArtifacts(await realpath(resolve(file)));
|
|
294
|
+
return { diagnostics: [] };
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
return { diagnostics: [failure("config", "PLC5004", cause)] };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, mkdir, rm, rename } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve, join } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import type { ResolvedTsplcConfig } from "@tsplc/config";
|
|
7
|
+
import { typeCheckOnly } from "@tsplc/compiler";
|
|
8
|
+
|
|
9
|
+
export const resolveServerModule = (root: string, name: string) =>
|
|
10
|
+
name.startsWith(".") || name.startsWith("/") || existsSync(resolve(root, name))
|
|
11
|
+
? resolve(root, name)
|
|
12
|
+
: createRequire(join(root, "package.json")).resolve(name);
|
|
13
|
+
|
|
14
|
+
const kebabCase = (name: string) =>
|
|
15
|
+
name
|
|
16
|
+
.replace(/([a-z0-9])([A-Z])|([A-Z])([A-Z][a-z])/g, "$1$3-$2$4")
|
|
17
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-|-$/g, "")
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
|
|
21
|
+
/** Compiles the host entry, which starts its own listener, into `build/server/<name>-services`. */
|
|
22
|
+
export const buildServer = async (config: ResolvedTsplcConfig, root: string) => {
|
|
23
|
+
const settings = config.server;
|
|
24
|
+
if (!settings?.runtime) throw new Error("Configure server.runtime to build an executable.");
|
|
25
|
+
const runtime = settings.runtime;
|
|
26
|
+
const entry = resolveServerModule(root, runtime.entry);
|
|
27
|
+
const windows = runtime.target
|
|
28
|
+
? runtime.target.includes("windows")
|
|
29
|
+
: process.platform === "win32";
|
|
30
|
+
const outFile = resolve(
|
|
31
|
+
root,
|
|
32
|
+
"build/server",
|
|
33
|
+
`${kebabCase(config.name)}-services${windows ? ".exe" : ""}`,
|
|
34
|
+
);
|
|
35
|
+
const tsconfig = ts.findConfigFile(dirname(entry), ts.sys.fileExists);
|
|
36
|
+
if (!tsconfig) throw new Error("Server entry requires a tsconfig.json.");
|
|
37
|
+
const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
|
|
38
|
+
if (read.error) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, "\n"));
|
|
39
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(tsconfig));
|
|
40
|
+
const program = ts.createProgram({
|
|
41
|
+
rootNames: [entry, ...parsed.fileNames.filter((file) => /\.d\.[cm]?ts$/.test(file))],
|
|
42
|
+
options: { ...parsed.options, ...typeCheckOnly },
|
|
43
|
+
projectReferences: parsed.projectReferences,
|
|
44
|
+
});
|
|
45
|
+
const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)];
|
|
46
|
+
if (diagnostics.length)
|
|
47
|
+
throw new Error(
|
|
48
|
+
ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
|
49
|
+
getCanonicalFileName: (path) => path,
|
|
50
|
+
getCurrentDirectory: () => root,
|
|
51
|
+
getNewLine: () => "\n",
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
await mkdir(dirname(outFile), { recursive: true });
|
|
55
|
+
// Compile beside the output so dependency resolution starts from the owning project.
|
|
56
|
+
const staging = await mkdtemp(join(dirname(outFile), ".tsplc-server-"));
|
|
57
|
+
try {
|
|
58
|
+
const binary = join(staging, "server");
|
|
59
|
+
const result = await Bun.build({
|
|
60
|
+
entrypoints: [entry],
|
|
61
|
+
env: "disable",
|
|
62
|
+
compile: {
|
|
63
|
+
outfile: binary,
|
|
64
|
+
...(runtime.target ? { target: runtime.target } : {}),
|
|
65
|
+
autoloadDotenv: false,
|
|
66
|
+
autoloadBunfig: false,
|
|
67
|
+
autoloadTsconfig: false,
|
|
68
|
+
autoloadPackageJson: false,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (!result.success) throw new AggregateError(result.logs, "Server executable build failed.");
|
|
72
|
+
// Bun names a Windows executable itself.
|
|
73
|
+
await rename(windows ? `${binary}.exe` : binary, outFile);
|
|
74
|
+
return {
|
|
75
|
+
server: {
|
|
76
|
+
executable: outFile,
|
|
77
|
+
target: runtime.target ?? `bun-${process.platform}-${process.arch}`,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(staging, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
};
|