@tsplc/compiler 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.
- 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 +1396 -0
- package/dist/cli.js.map +16 -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 +429 -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 +106 -4
- package/project/artifacts.ts +277 -0
- package/project/cli.ts +305 -0
- package/project/index.ts +1 -0
- package/project/plant.ts +437 -0
- package/project/project.ts +312 -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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { error, type Diagnostic } from "@tsplc/compiler/ir";
|
|
4
|
+
import { runtimeSources } from "../module-graph.ts";
|
|
5
|
+
import { captureGraph } from "./graph.ts";
|
|
6
|
+
import { Model, type Root } from "./model.ts";
|
|
7
|
+
import { setterName, Specializer, SpecializeError } from "./specialize.ts";
|
|
8
|
+
|
|
9
|
+
/** Directory of the modules elaboration generates, beside the entry module. */
|
|
10
|
+
const generatedDirectory = ".tsplc";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A module the compiled program keeps as written: only types, enums, constants and the service
|
|
14
|
+
* client. Everything else is plain TypeScript that elaboration compiles from its setup graph.
|
|
15
|
+
*/
|
|
16
|
+
const keptModule = (source: ts.SourceFile, sdk: string): boolean =>
|
|
17
|
+
source.statements.every((statement) => {
|
|
18
|
+
if (
|
|
19
|
+
ts.isImportDeclaration(statement) ||
|
|
20
|
+
ts.isExportDeclaration(statement) ||
|
|
21
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
22
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
23
|
+
ts.isEnumDeclaration(statement)
|
|
24
|
+
)
|
|
25
|
+
return true;
|
|
26
|
+
if (!ts.isVariableStatement(statement)) return false;
|
|
27
|
+
return statement.declarationList.declarations.every(
|
|
28
|
+
(declaration) => declaration.initializer && constantInitializer(declaration.initializer, sdk),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const constantInitializer = (node: ts.Expression, sdk: string): boolean => {
|
|
33
|
+
if (
|
|
34
|
+
ts.isNumericLiteral(node) ||
|
|
35
|
+
ts.isStringLiteral(node) ||
|
|
36
|
+
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
37
|
+
ts.isBigIntLiteral(node) ||
|
|
38
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
39
|
+
node.kind === ts.SyntaxKind.FalseKeyword
|
|
40
|
+
)
|
|
41
|
+
return true;
|
|
42
|
+
if (ts.isPrefixUnaryExpression(node)) return constantInitializer(node.operand, sdk);
|
|
43
|
+
if (
|
|
44
|
+
ts.isAsExpression(node) ||
|
|
45
|
+
ts.isSatisfiesExpression(node) ||
|
|
46
|
+
ts.isParenthesizedExpression(node)
|
|
47
|
+
)
|
|
48
|
+
return constantInitializer(node.expression, sdk);
|
|
49
|
+
if (ts.isPropertyAccessExpression(node)) return ts.isIdentifier(node.expression);
|
|
50
|
+
if (ts.isIdentifier(node)) return true;
|
|
51
|
+
if (ts.isObjectLiteralExpression(node))
|
|
52
|
+
return node.properties.every(
|
|
53
|
+
(property) =>
|
|
54
|
+
(ts.isPropertyAssignment(property) && constantInitializer(property.initializer, sdk)) ||
|
|
55
|
+
ts.isShorthandPropertyAssignment(property),
|
|
56
|
+
);
|
|
57
|
+
if (ts.isArrayLiteralExpression(node))
|
|
58
|
+
return node.elements.every((element) => constantInitializer(element, sdk));
|
|
59
|
+
if (ts.isCallExpression(node))
|
|
60
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "createServiceClient";
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Whether the program is plain TypeScript: classes with constructors, or setup code building state. */
|
|
65
|
+
const needsElaboration = (sources: readonly ts.SourceFile[], sdk: string): boolean =>
|
|
66
|
+
sources.some(
|
|
67
|
+
(source) =>
|
|
68
|
+
!source.isDeclarationFile &&
|
|
69
|
+
!source.fileName.startsWith(`${dirname(sdk)}/`) &&
|
|
70
|
+
!keptModule(source, sdk) &&
|
|
71
|
+
source.statements.some(
|
|
72
|
+
(statement) =>
|
|
73
|
+
ts.isClassDeclaration(statement) &&
|
|
74
|
+
!statement.heritageClauses?.some((clause) =>
|
|
75
|
+
clause.types.some((type) => type.expression.getText().startsWith("FunctionBlock")),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const importPath = (from: string, to: string): string => {
|
|
81
|
+
const path = relative(dirname(from), to)
|
|
82
|
+
.replace(/\\/g, "/")
|
|
83
|
+
.replace(/\.tsx?$/, "");
|
|
84
|
+
return path.startsWith("./") || path.startsWith("../") ? path : `./${path}`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compiles a plain TypeScript program: runs its setup, then specializes its scan code against
|
|
89
|
+
* the objects setup built. The result is tsPLC dialect source the rest of the compiler reads.
|
|
90
|
+
*/
|
|
91
|
+
export const lowerElaboration = (
|
|
92
|
+
program: ts.Program,
|
|
93
|
+
sdk: string,
|
|
94
|
+
entries: Readonly<Record<string, { module: string; export: string }>>,
|
|
95
|
+
root: string,
|
|
96
|
+
): { sources: Map<string, string>; diagnostics: Diagnostic[] } => {
|
|
97
|
+
const sources = new Map<string, string>();
|
|
98
|
+
const diagnostics: Diagnostic[] = [];
|
|
99
|
+
const runtime = runtimeSources(program).filter(
|
|
100
|
+
(source) => !source.isDeclarationFile && !source.fileName.startsWith(`${dirname(sdk)}/`),
|
|
101
|
+
);
|
|
102
|
+
if (!needsElaboration(runtime, sdk)) return { sources, diagnostics };
|
|
103
|
+
const programEntries = Object.values(entries);
|
|
104
|
+
if (programEntries.length !== 1)
|
|
105
|
+
return {
|
|
106
|
+
sources,
|
|
107
|
+
diagnostics: [error("PLC1001", "A plain TypeScript program has one entry function.")],
|
|
108
|
+
};
|
|
109
|
+
const [entry] = programEntries as [{ module: string; export: string }];
|
|
110
|
+
const entryFile = resolve(root, entry.module).replace(/\\/g, "/");
|
|
111
|
+
const entrySource = program.getSourceFile(entryFile);
|
|
112
|
+
if (!entrySource)
|
|
113
|
+
return {
|
|
114
|
+
sources,
|
|
115
|
+
diagnostics: [error("PLC1001", `${entry.module} is not part of the program.`)],
|
|
116
|
+
};
|
|
117
|
+
const checker = program.getTypeChecker();
|
|
118
|
+
const kept = new Set(
|
|
119
|
+
runtime.filter((source) => keptModule(source, sdk)).map((source) => source.fileName),
|
|
120
|
+
);
|
|
121
|
+
const isKept = (file: string) => kept.has(file);
|
|
122
|
+
const directory = join(dirname(entryFile), generatedDirectory).replace(/\\/g, "/");
|
|
123
|
+
const rootModule = (item: Root) => `${directory}/${item.name}.ts`;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const graph = captureGraph(entryFile, runtime);
|
|
127
|
+
const model = new Model(graph, checker, program, entryFile);
|
|
128
|
+
const specializer = new Specializer(model, checker, runtime, isKept, rootModule);
|
|
129
|
+
const main = findEntry(entrySource, entry.export);
|
|
130
|
+
const body = specializer.entry(main);
|
|
131
|
+
|
|
132
|
+
// Exported data the hardware, HMI and server address is stored whether or not code reads it.
|
|
133
|
+
for (const item of model.roots)
|
|
134
|
+
if (item.exported && !isKept(item.file) && item.file === entryFile) specializer.use(item);
|
|
135
|
+
|
|
136
|
+
const typesFile = `${directory}/types.ts`;
|
|
137
|
+
const storage = new Map<string, string[]>();
|
|
138
|
+
for (const item of specializer.roots) {
|
|
139
|
+
const value = model.rootValues.get(item)!;
|
|
140
|
+
const node = model.node(value)!;
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
// Other modules can't assign an imported binding, so each global comes with a setter.
|
|
143
|
+
const global = (name: string, layout: string, initial: string) =>
|
|
144
|
+
lines.push(
|
|
145
|
+
`export let ${name}: ${layout} = ${initial};`,
|
|
146
|
+
`export function ${setterName(name)}(value: ${layout}): void { ${name} = value; }`,
|
|
147
|
+
);
|
|
148
|
+
if (specializer.flattened(item)) {
|
|
149
|
+
const captured = node.captured;
|
|
150
|
+
if (captured.kind === "instance") {
|
|
151
|
+
for (const [name, layout] of specializer.instanceFields(node))
|
|
152
|
+
global(name, layout, specializer.instanceFieldInitializer(node, name, layout));
|
|
153
|
+
} else if (captured.kind === "object") {
|
|
154
|
+
for (const [name, field] of Object.entries(captured.fields)) {
|
|
155
|
+
const inner = model.node(field);
|
|
156
|
+
const declared = node.type && model.fieldType(node.type, name, captured);
|
|
157
|
+
if (!declared) continue;
|
|
158
|
+
const stored = item.exported || specializer.stored(node, name);
|
|
159
|
+
if (!stored && !inner) continue;
|
|
160
|
+
const layout =
|
|
161
|
+
inner && !item.exported
|
|
162
|
+
? specializer.storageType(inner)
|
|
163
|
+
: specializer.layouts.declaredText(
|
|
164
|
+
node.type?.getProperty(name)?.valueDeclaration,
|
|
165
|
+
declared,
|
|
166
|
+
field,
|
|
167
|
+
name.charAt(0).toUpperCase() + name.slice(1),
|
|
168
|
+
);
|
|
169
|
+
if (!layout) continue;
|
|
170
|
+
global(name, layout, specializer.valueText(field, declared, layout));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
const declared = checker.getTypeAtLocation(item.declaration.name);
|
|
175
|
+
const layout = specializer.layouts.declaredText(item.declaration, declared, value);
|
|
176
|
+
global(item.name, layout, specializer.valueText(value, declared, layout));
|
|
177
|
+
}
|
|
178
|
+
storage.set(rootModule(item), lines);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const typeImports = (file: string) => [
|
|
182
|
+
...[...specializer.layouts.imports].map(
|
|
183
|
+
([module, names]) =>
|
|
184
|
+
`import { ${[...names].join(", ")} } from "${importPath(file, module)}";`,
|
|
185
|
+
),
|
|
186
|
+
...[...specializer.layouts.typeImports].map(
|
|
187
|
+
([module, names]) =>
|
|
188
|
+
`import type { ${[...names].join(", ")} } from "${importPath(file, module)}";`,
|
|
189
|
+
),
|
|
190
|
+
];
|
|
191
|
+
const enums = specializer.layouts.enumNames;
|
|
192
|
+
const interfaces = [...specializer.layouts.declarations.keys()].filter(
|
|
193
|
+
(name) => !enums.includes(name),
|
|
194
|
+
);
|
|
195
|
+
const importTypes = (file: string) => [
|
|
196
|
+
...(enums.length
|
|
197
|
+
? [`import { ${enums.join(", ")} } from "${importPath(file, typesFile)}";`]
|
|
198
|
+
: []),
|
|
199
|
+
...(interfaces.length
|
|
200
|
+
? [`import type { ${interfaces.join(", ")} } from "${importPath(file, typesFile)}";`]
|
|
201
|
+
: []),
|
|
202
|
+
];
|
|
203
|
+
sources.set(
|
|
204
|
+
typesFile,
|
|
205
|
+
[...typeImports(typesFile), ...specializer.layouts.declarations.values()].join("\n"),
|
|
206
|
+
);
|
|
207
|
+
for (const [file, lines] of storage)
|
|
208
|
+
sources.set(file, [...typeImports(file), ...importTypes(file), ...lines].join("\n"));
|
|
209
|
+
|
|
210
|
+
const header = [
|
|
211
|
+
...typeImports(entryFile),
|
|
212
|
+
...importTypes(entryFile),
|
|
213
|
+
...[...specializer.namespaces].map(
|
|
214
|
+
([name, file]) => `import * as ${name} from "${importPath(entryFile, file)}";`,
|
|
215
|
+
),
|
|
216
|
+
...[...specializer.imports].map(
|
|
217
|
+
([file, names]) =>
|
|
218
|
+
`import { ${[...names].join(", ")} } from "${importPath(entryFile, file)}";`,
|
|
219
|
+
),
|
|
220
|
+
...(specializer.sdkImports.size
|
|
221
|
+
? [`import { ${[...specializer.sdkImports].join(", ")} } from "@tsplc/compiler/clock";`]
|
|
222
|
+
: []),
|
|
223
|
+
...(specializer.typeImports.size
|
|
224
|
+
? [`import { ${[...specializer.typeImports].join(", ")} } from "@tsplc/compiler/types";`]
|
|
225
|
+
: []),
|
|
226
|
+
];
|
|
227
|
+
sources.set(
|
|
228
|
+
entryFile,
|
|
229
|
+
[
|
|
230
|
+
...header,
|
|
231
|
+
...specializer.tableDeclarations,
|
|
232
|
+
...specializer.routines,
|
|
233
|
+
`export function ${entry.export}(): void {`,
|
|
234
|
+
...body,
|
|
235
|
+
"}",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
);
|
|
238
|
+
// The plain modules now live in the generated code; keep only their types for type importers.
|
|
239
|
+
for (const source of runtime)
|
|
240
|
+
if (source.fileName !== entryFile && !isKept(source.fileName))
|
|
241
|
+
sources.set(
|
|
242
|
+
source.fileName,
|
|
243
|
+
[
|
|
244
|
+
...source.statements
|
|
245
|
+
.filter(
|
|
246
|
+
(statement) =>
|
|
247
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
248
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
249
|
+
(ts.isImportDeclaration(statement) && statement.importClause?.isTypeOnly),
|
|
250
|
+
)
|
|
251
|
+
.map((statement) => statement.getText()),
|
|
252
|
+
"export {};",
|
|
253
|
+
].join("\n"),
|
|
254
|
+
);
|
|
255
|
+
} catch (cause) {
|
|
256
|
+
if (cause instanceof SpecializeError) {
|
|
257
|
+
const start = cause.node.getStart();
|
|
258
|
+
const source = cause.node.getSourceFile();
|
|
259
|
+
const position = source.getLineAndCharacterOfPosition(start);
|
|
260
|
+
diagnostics.push(
|
|
261
|
+
error("PLC1010", cause.message, {
|
|
262
|
+
file: source.fileName,
|
|
263
|
+
start,
|
|
264
|
+
end: cause.node.getEnd(),
|
|
265
|
+
line: position.line + 1,
|
|
266
|
+
column: position.character + 1,
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
} else
|
|
270
|
+
diagnostics.push(
|
|
271
|
+
error(
|
|
272
|
+
"PLC1010",
|
|
273
|
+
cause instanceof Error
|
|
274
|
+
? process.env.TSPLC_DEBUG
|
|
275
|
+
? String(cause.stack)
|
|
276
|
+
: cause.message
|
|
277
|
+
: String(cause),
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return { sources, diagnostics };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const findEntry = (
|
|
285
|
+
source: ts.SourceFile,
|
|
286
|
+
name: string,
|
|
287
|
+
): ts.FunctionDeclaration | ts.ArrowFunction => {
|
|
288
|
+
for (const statement of source.statements) {
|
|
289
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === name && statement.body)
|
|
290
|
+
return statement;
|
|
291
|
+
if (ts.isVariableStatement(statement))
|
|
292
|
+
for (const declaration of statement.declarationList.declarations)
|
|
293
|
+
if (
|
|
294
|
+
ts.isIdentifier(declaration.name) &&
|
|
295
|
+
declaration.name.text === name &&
|
|
296
|
+
declaration.initializer &&
|
|
297
|
+
ts.isArrowFunction(declaration.initializer)
|
|
298
|
+
)
|
|
299
|
+
return declaration.initializer;
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`${source.fileName} exports no function ${name}.`);
|
|
302
|
+
};
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { CapturedValue } from "./graph.ts";
|
|
3
|
+
import type { Model } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
/** IEC names a program can write as types; they keep their width rather than becoming DINT. */
|
|
6
|
+
const iecNames = new Set([
|
|
7
|
+
"BOOL",
|
|
8
|
+
"SINT",
|
|
9
|
+
"INT",
|
|
10
|
+
"DINT",
|
|
11
|
+
"LINT",
|
|
12
|
+
"USINT",
|
|
13
|
+
"UINT",
|
|
14
|
+
"UDINT",
|
|
15
|
+
"ULINT",
|
|
16
|
+
"BYTE",
|
|
17
|
+
"WORD",
|
|
18
|
+
"DWORD",
|
|
19
|
+
"LWORD",
|
|
20
|
+
"REAL",
|
|
21
|
+
"LREAL",
|
|
22
|
+
"TIME",
|
|
23
|
+
"DATE",
|
|
24
|
+
"TIME_OF_DAY",
|
|
25
|
+
"DATE_AND_TIME",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** A union of string literals: a PLC enum. */
|
|
29
|
+
export interface LiteralEnum {
|
|
30
|
+
name: string;
|
|
31
|
+
members: string[];
|
|
32
|
+
/** The union also allows undefined, stored as this extra member. */
|
|
33
|
+
optional: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Turns the program's TypeScript types into the PLC dialect's: string-literal unions become enums,
|
|
38
|
+
* unions of objects become one record tagged by their literal field, `T | undefined` becomes an
|
|
39
|
+
* optional record, and arrays take their captured length. Declarations it needs collect in `declarations`.
|
|
40
|
+
*/
|
|
41
|
+
export class Layouts {
|
|
42
|
+
readonly declarations = new Map<string, string>();
|
|
43
|
+
/** Enums the declarations need from kept modules, by module file. */
|
|
44
|
+
readonly imports = new Map<string, Set<string>>();
|
|
45
|
+
/** Types the declarations need from kept modules, by module file. */
|
|
46
|
+
readonly typeImports = new Map<string, Set<string>>();
|
|
47
|
+
private readonly enums = new Map<string, LiteralEnum>();
|
|
48
|
+
private readonly records = new Map<string, string>();
|
|
49
|
+
private readonly names = new Map<string, number>();
|
|
50
|
+
|
|
51
|
+
/** Exported types of kept modules by the type they declare, for types that lost their alias name. */
|
|
52
|
+
private readonly keptTypes = new Map<ts.Type, { file: string; name: string }>();
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly model: Model,
|
|
56
|
+
private readonly checker: ts.TypeChecker,
|
|
57
|
+
/** Modules the compiled program keeps, whose named types and enums it imports rather than copies. */
|
|
58
|
+
private readonly kept: (file: string) => boolean,
|
|
59
|
+
keptSources: readonly ts.SourceFile[] = [],
|
|
60
|
+
) {
|
|
61
|
+
for (const source of keptSources)
|
|
62
|
+
for (const statement of source.statements)
|
|
63
|
+
if (
|
|
64
|
+
(ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
|
|
65
|
+
isExported(statement)
|
|
66
|
+
) {
|
|
67
|
+
const symbol = checker.getSymbolAtLocation(statement.name);
|
|
68
|
+
if (symbol)
|
|
69
|
+
this.keptTypes.set(checker.getDeclaredTypeOfSymbol(symbol), {
|
|
70
|
+
file: source.fileName,
|
|
71
|
+
name: statement.name.text,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private unique(base: string): string {
|
|
77
|
+
const count = this.names.get(base) ?? 0;
|
|
78
|
+
this.names.set(base, count + 1);
|
|
79
|
+
return count ? `${base}_${count + 1}` : base;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private import(file: string, name: string, kind: "value" | "type" = "value"): string {
|
|
83
|
+
const imports = kind === "value" ? this.imports : this.typeImports;
|
|
84
|
+
const names = imports.get(file) ?? new Set<string>();
|
|
85
|
+
names.add(name);
|
|
86
|
+
imports.set(file, names);
|
|
87
|
+
return name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Generated enums, which modules import as values. */
|
|
91
|
+
get enumNames(): string[] {
|
|
92
|
+
return [...this.declarations]
|
|
93
|
+
.filter(([, text]) => text.startsWith("export enum"))
|
|
94
|
+
.map(([name]) => name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The string literals of a union of them, without undefined. */
|
|
98
|
+
literals(type: ts.Type): { members: string[]; optional: boolean } | undefined {
|
|
99
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
100
|
+
const members: string[] = [];
|
|
101
|
+
let optional = false;
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
if (part.flags & ts.TypeFlags.Undefined) optional = true;
|
|
104
|
+
else if (part.isStringLiteral()) members.push(part.value);
|
|
105
|
+
else return undefined;
|
|
106
|
+
}
|
|
107
|
+
return members.length ? { members, optional } : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The enum a string-literal union compiles to. */
|
|
111
|
+
literalEnum(type: ts.Type): LiteralEnum | undefined {
|
|
112
|
+
const literals = this.literals(type);
|
|
113
|
+
if (!literals) return undefined;
|
|
114
|
+
const key = [...literals.members].sort().join("|");
|
|
115
|
+
const existing = this.enums.get(key);
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.optional ||= literals.optional;
|
|
118
|
+
this.writeEnum(existing);
|
|
119
|
+
return existing;
|
|
120
|
+
}
|
|
121
|
+
const alias = (type.aliasSymbol ?? this.checker.getNonNullableType(type).aliasSymbol)?.name;
|
|
122
|
+
const declared: LiteralEnum = {
|
|
123
|
+
name: this.unique(alias ?? pascal(literals.members.join("_or_"))),
|
|
124
|
+
members: literals.members,
|
|
125
|
+
optional: literals.optional,
|
|
126
|
+
};
|
|
127
|
+
this.enums.set(key, declared);
|
|
128
|
+
this.writeEnum(declared);
|
|
129
|
+
return declared;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private writeEnum(declared: LiteralEnum): void {
|
|
133
|
+
const members = [
|
|
134
|
+
...(declared.optional ? ["Undefined = 0"] : []),
|
|
135
|
+
...declared.members.map((member, index) => `${enumMember(member)} = ${index + 1}`),
|
|
136
|
+
];
|
|
137
|
+
this.declarations.set(declared.name, `export enum ${declared.name} { ${members.join(", ")} }`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `Enum.Member` text for a string literal of a literal union. */
|
|
141
|
+
member(declared: LiteralEnum, value: string | undefined): string {
|
|
142
|
+
return `${declared.name}.${value === undefined ? "Undefined" : enumMember(value)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The object types of a union of them, without undefined, when it has a literal tag field. */
|
|
146
|
+
tagged(type: ts.Type): { tag: string; variants: ts.Type[]; optional: boolean } | undefined {
|
|
147
|
+
if (!type.isUnion()) return undefined;
|
|
148
|
+
const variants = type.types.filter((part) => !(part.flags & ts.TypeFlags.Undefined));
|
|
149
|
+
if (variants.length < 2 || variants.some((part) => !(part.flags & ts.TypeFlags.Object)))
|
|
150
|
+
return undefined;
|
|
151
|
+
const first = variants[0]!;
|
|
152
|
+
for (const property of first.getProperties()) {
|
|
153
|
+
const literal = (variant: ts.Type) => {
|
|
154
|
+
const own = variant.getProperty(property.name);
|
|
155
|
+
const value = own && this.checker.getTypeOfSymbol(own);
|
|
156
|
+
return value?.isStringLiteral() ? value.value : undefined;
|
|
157
|
+
};
|
|
158
|
+
if (variants.every((variant) => literal(variant) !== undefined))
|
|
159
|
+
return {
|
|
160
|
+
tag: property.name,
|
|
161
|
+
variants,
|
|
162
|
+
optional: variants.length !== type.types.length,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The dialect type text for `type`, holding `value` when it was captured. */
|
|
169
|
+
text(type: ts.Type, value?: CapturedValue, hint?: string): string {
|
|
170
|
+
const checker = this.checker;
|
|
171
|
+
const alias = type.aliasSymbol?.name;
|
|
172
|
+
if (alias && iecNames.has(alias)) return alias;
|
|
173
|
+
if (type.flags & ts.TypeFlags.BooleanLike) return "boolean";
|
|
174
|
+
const enumDeclaration = this.enumDeclaration(type);
|
|
175
|
+
if (enumDeclaration) return this.enumName(enumDeclaration);
|
|
176
|
+
if (type.flags & ts.TypeFlags.NumberLike) return "number";
|
|
177
|
+
if (type.flags & ts.TypeFlags.BigIntLike) return "LINT";
|
|
178
|
+
const literal = this.literalEnum(type);
|
|
179
|
+
if (literal) return literal.name;
|
|
180
|
+
const named = this.namedKept(type);
|
|
181
|
+
if (named) return named;
|
|
182
|
+
if (type.flags & ts.TypeFlags.StringLike) return "string";
|
|
183
|
+
const tagged = this.tagged(type);
|
|
184
|
+
if (tagged) return this.taggedRecord(type, tagged);
|
|
185
|
+
if (type.isUnion()) {
|
|
186
|
+
const defined = checker.getNonNullableType(type);
|
|
187
|
+
if (defined !== type) return this.optional(defined, value);
|
|
188
|
+
}
|
|
189
|
+
const element = this.model.elementType(type);
|
|
190
|
+
if (
|
|
191
|
+
element &&
|
|
192
|
+
(checker.isArrayType(type) || checker.isTupleType(type) || isReadonlyArray(type))
|
|
193
|
+
) {
|
|
194
|
+
const node = value && this.model.node(value);
|
|
195
|
+
if (!node || node.captured.kind !== "array")
|
|
196
|
+
throw new Error(`${checker.typeToString(type)} needs a length: build it during setup.`);
|
|
197
|
+
const items = node.captured.items;
|
|
198
|
+
return `FixedArray<${this.text(element, items[0] ?? this.sample(element))}, ${Math.max(items.length, 1)}>`;
|
|
199
|
+
}
|
|
200
|
+
const enumKey = this.enumRecord(type);
|
|
201
|
+
if (enumKey) return enumKey;
|
|
202
|
+
if (type.flags & ts.TypeFlags.Object) return this.record(type, value, hint);
|
|
203
|
+
throw new Error(`${checker.typeToString(type)} has no PLC layout.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The TypeScript enum a type is, or is a union of members of. */
|
|
207
|
+
enumDeclaration(type: ts.Type): ts.EnumDeclaration | undefined {
|
|
208
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
209
|
+
if (!parts.length || !parts.every((part) => part.flags & ts.TypeFlags.EnumLike))
|
|
210
|
+
return undefined;
|
|
211
|
+
const symbol = parts[0]!.symbol;
|
|
212
|
+
const declaration =
|
|
213
|
+
symbol?.declarations?.find(ts.isEnumDeclaration) ?? symbol?.valueDeclaration?.parent;
|
|
214
|
+
return declaration && ts.isEnumDeclaration(declaration) ? declaration : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The IEC type a declaration names, such as `WORD`: TypeScript keeps no alias name for an alias
|
|
219
|
+
* of a primitive, so the width is read from the declaration's annotation.
|
|
220
|
+
*/
|
|
221
|
+
iecName(declaration: ts.Node | undefined): string | undefined {
|
|
222
|
+
const node = declaration && (declaration as { type?: ts.TypeNode }).type;
|
|
223
|
+
return node && ts.isTypeReferenceNode(node) && iecNames.has(node.typeName.getText())
|
|
224
|
+
? node.typeName.getText()
|
|
225
|
+
: undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A declared field's layout, keeping the IEC widths its annotation names. */
|
|
229
|
+
declaredText(
|
|
230
|
+
declaration: ts.Node | undefined,
|
|
231
|
+
type: ts.Type,
|
|
232
|
+
value?: CapturedValue,
|
|
233
|
+
hint?: string,
|
|
234
|
+
): string {
|
|
235
|
+
const iec = this.iecName(declaration);
|
|
236
|
+
if (iec) return iec;
|
|
237
|
+
const node = declaration && (declaration as { type?: ts.TypeNode }).type;
|
|
238
|
+
const element =
|
|
239
|
+
node && ts.isArrayTypeNode(node)
|
|
240
|
+
? node.elementType
|
|
241
|
+
: node &&
|
|
242
|
+
ts.isTypeReferenceNode(node) &&
|
|
243
|
+
["Array", "ReadonlyArray"].includes(node.typeName.getText())
|
|
244
|
+
? node.typeArguments?.[0]
|
|
245
|
+
: undefined;
|
|
246
|
+
const elementName =
|
|
247
|
+
element && ts.isTypeReferenceNode(element) ? element.typeName.getText() : undefined;
|
|
248
|
+
if (elementName && iecNames.has(elementName)) {
|
|
249
|
+
const captured = value && this.model.node(value);
|
|
250
|
+
if (captured?.captured.kind !== "array")
|
|
251
|
+
throw new Error(
|
|
252
|
+
`${this.checker.typeToString(type)} needs a length: build it during setup.`,
|
|
253
|
+
);
|
|
254
|
+
return `FixedArray<${elementName}, ${Math.max(captured.captured.items.length, 1)}>`;
|
|
255
|
+
}
|
|
256
|
+
return this.text(type, value, hint);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A captured value of a field, from any captured record that has it. */
|
|
260
|
+
private fieldSample(property: ts.Symbol): CapturedValue | undefined {
|
|
261
|
+
for (const node of this.model.nodes) {
|
|
262
|
+
const captured = node.captured;
|
|
263
|
+
if (
|
|
264
|
+
(captured.kind === "object" || captured.kind === "instance") &&
|
|
265
|
+
node.type?.getProperty(property.name) === property
|
|
266
|
+
)
|
|
267
|
+
return captured.fields[property.name];
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Some captured value of `type`, giving the lengths of an empty array's element. */
|
|
273
|
+
private sample(type: ts.Type): CapturedValue | undefined {
|
|
274
|
+
const node = this.model.nodes.find((candidate) => candidate.type === type);
|
|
275
|
+
return node && { kind: "node", id: node.id };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A named type from a module the program keeps, such as a service's procedure record. */
|
|
279
|
+
private namedKept(type: ts.Type): string | undefined {
|
|
280
|
+
const declared = this.keptTypes.get(type);
|
|
281
|
+
if (declared) return this.import(declared.file, declared.name, "type");
|
|
282
|
+
const symbol = type.aliasSymbol ?? type.getSymbol();
|
|
283
|
+
const declaration = symbol?.declarations?.[0];
|
|
284
|
+
if (!symbol || !declaration) return undefined;
|
|
285
|
+
if (!ts.isTypeAliasDeclaration(declaration) && !ts.isInterfaceDeclaration(declaration))
|
|
286
|
+
return undefined;
|
|
287
|
+
const file = declaration.getSourceFile().fileName;
|
|
288
|
+
if (!this.kept(file) || !isExported(declaration)) return undefined;
|
|
289
|
+
return this.import(file, symbol.name, "type");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private enumName(declaration: ts.EnumDeclaration): string {
|
|
293
|
+
const file = declaration.getSourceFile().fileName;
|
|
294
|
+
if (!this.kept(file))
|
|
295
|
+
throw new Error(
|
|
296
|
+
`${declaration.name.text}: declare PLC enums in a module of enums and constants, so the server and PLC share them.`,
|
|
297
|
+
);
|
|
298
|
+
return this.import(file, declaration.name.text);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** `Record<Enum, T>`: an object type whose properties are exactly an enum's members. */
|
|
302
|
+
private enumRecord(type: ts.Type): string | undefined {
|
|
303
|
+
const alias = type.aliasSymbol;
|
|
304
|
+
if (alias?.name !== "Record" || type.aliasTypeArguments?.length !== 2) return undefined;
|
|
305
|
+
const [key, element] = type.aliasTypeArguments;
|
|
306
|
+
if (!this.enumDeclaration(key!) && !this.literalEnum(key!)) return undefined;
|
|
307
|
+
return `Record<${this.text(key!)}, ${this.text(element!, undefined)}>`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `{ has, value }` for `T | undefined`. */
|
|
311
|
+
private optional(defined: ts.Type, value: CapturedValue | undefined): string {
|
|
312
|
+
const inner = this.text(defined, value?.kind === "undefined" ? undefined : value);
|
|
313
|
+
const name = `Optional_${inner.replace(/\W+/g, "_")}`;
|
|
314
|
+
if (!this.declarations.has(name))
|
|
315
|
+
this.declarations.set(name, `export interface ${name} { has: boolean; value: ${inner}; }`);
|
|
316
|
+
return name;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** One record for a tagged union: the tag as an enum, and every variant's fields. */
|
|
320
|
+
private taggedRecord(
|
|
321
|
+
type: ts.Type,
|
|
322
|
+
tagged: { tag: string; variants: ts.Type[]; optional: boolean },
|
|
323
|
+
): string {
|
|
324
|
+
const key = `tagged:${this.checker.typeToString(type)}`;
|
|
325
|
+
const known = this.records.get(key);
|
|
326
|
+
if (known) return known;
|
|
327
|
+
const name = this.unique(type.aliasSymbol?.name ?? "Variant");
|
|
328
|
+
this.records.set(key, name);
|
|
329
|
+
const tags = tagged.variants.map((variant) => {
|
|
330
|
+
const property = variant.getProperty(tagged.tag)!;
|
|
331
|
+
return (this.checker.getTypeOfSymbol(property) as ts.StringLiteralType).value;
|
|
332
|
+
});
|
|
333
|
+
const tagEnum: LiteralEnum = { name: `${name}Kind`, members: tags, optional: tagged.optional };
|
|
334
|
+
this.enums.set([...tags].sort().join("|"), tagEnum);
|
|
335
|
+
this.writeEnum(tagEnum);
|
|
336
|
+
const fields = new Map<string, string>();
|
|
337
|
+
for (const variant of tagged.variants)
|
|
338
|
+
for (const property of variant.getProperties()) {
|
|
339
|
+
if (property.name === tagged.tag || fields.has(property.name)) continue;
|
|
340
|
+
fields.set(
|
|
341
|
+
property.name,
|
|
342
|
+
this.declaredText(property.valueDeclaration, this.checker.getTypeOfSymbol(property)),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
this.declarations.set(
|
|
346
|
+
name,
|
|
347
|
+
`export interface ${name} { ${tagged.tag}: ${tagEnum.name}; ${[...fields]
|
|
348
|
+
.map(([field, text]) => `${field}: ${text};`)
|
|
349
|
+
.join(" ")} }`,
|
|
350
|
+
);
|
|
351
|
+
return name;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A record of an object type; captured arrays inside it can give it distinct variants. */
|
|
355
|
+
private record(type: ts.Type, value: CapturedValue | undefined, hint?: string): string {
|
|
356
|
+
const node = value && this.model.node(value);
|
|
357
|
+
const fields = this.checker.getPropertiesOfType(type).map((property) => {
|
|
358
|
+
const captured =
|
|
359
|
+
node && (node.captured.kind === "object" || node.captured.kind === "instance")
|
|
360
|
+
? node.captured.fields[property.name]
|
|
361
|
+
: undefined;
|
|
362
|
+
return `${property.name}: ${this.declaredText(property.valueDeclaration, this.checker.getTypeOfSymbol(property), captured ?? this.fieldSample(property), pascal(property.name))};`;
|
|
363
|
+
});
|
|
364
|
+
const body = fields.join(" ");
|
|
365
|
+
const base = type.aliasSymbol?.name ?? type.getSymbol()?.name;
|
|
366
|
+
const key = `${base}:${body}`;
|
|
367
|
+
const known = this.records.get(key);
|
|
368
|
+
if (known) return known;
|
|
369
|
+
const name = this.unique(
|
|
370
|
+
base && base !== "__type" && base !== "__object" ? base : (hint ?? "Fields"),
|
|
371
|
+
);
|
|
372
|
+
this.records.set(key, name);
|
|
373
|
+
this.declarations.set(name, `export interface ${name} { ${body} }`);
|
|
374
|
+
return name;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const isExported = (declaration: ts.Declaration): boolean =>
|
|
379
|
+
Boolean(ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Export);
|
|
380
|
+
|
|
381
|
+
const isReadonlyArray = (type: ts.Type): boolean => type.getSymbol()?.name === "ReadonlyArray";
|
|
382
|
+
|
|
383
|
+
const pascal = (text: string): string =>
|
|
384
|
+
text.replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, letter: string) => letter.toUpperCase());
|
|
385
|
+
|
|
386
|
+
/** A string literal as an enum member name. */
|
|
387
|
+
export const enumMember = (value: string): string => {
|
|
388
|
+
const name = pascal(value);
|
|
389
|
+
return /^[A-Za-z_]/.test(name) ? name : `_${name}`;
|
|
390
|
+
};
|