@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.
Files changed (80) 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 +1396 -0
  18. package/dist/cli.js.map +16 -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 +429 -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 +106 -4
  48. package/project/artifacts.ts +277 -0
  49. package/project/cli.ts +305 -0
  50. package/project/index.ts +1 -0
  51. package/project/plant.ts +437 -0
  52. package/project/project.ts +312 -0
  53. package/project/server.ts +83 -0
  54. package/src/frontend/async.ts +1060 -0
  55. package/src/frontend/blocks.ts +419 -0
  56. package/src/frontend/configuration.ts +167 -0
  57. package/src/frontend/const-functions.ts +153 -0
  58. package/src/frontend/cooperative.ts +75 -0
  59. package/src/frontend/elaborate/capture.ts +138 -0
  60. package/src/frontend/elaborate/graph.ts +94 -0
  61. package/src/frontend/elaborate/index.ts +302 -0
  62. package/src/frontend/elaborate/layouts.ts +390 -0
  63. package/src/frontend/elaborate/model.ts +177 -0
  64. package/src/frontend/elaborate/specialize.ts +2280 -0
  65. package/src/frontend/exposure.ts +147 -0
  66. package/src/frontend/module-graph.ts +63 -0
  67. package/src/frontend/promises.ts +751 -0
  68. package/src/frontend/services.ts +435 -0
  69. package/src/frontend/types.ts +578 -0
  70. package/src/frontend/typescript.ts +2442 -0
  71. package/src/index.ts +7 -0
  72. package/stdlib/clock.ts +2 -0
  73. package/stdlib/math.ts +19 -0
  74. package/types/async.d.ts +81 -0
  75. package/types/clock.d.ts +4 -0
  76. package/types/config.d.ts +2 -0
  77. package/types/data-types.d.ts +31 -0
  78. package/types/globals.d.ts +40 -0
  79. package/types/index.d.ts +111 -0
  80. package/types/plc.d.ts +5 -0
@@ -0,0 +1,2442 @@
1
+ import { runtimeSources } from "./module-graph.ts";
2
+ import ts from "typescript";
3
+ import { createRequire } from "node:module";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import {
7
+ instancedBlock,
8
+ error,
9
+ hasErrors,
10
+ parseInteger,
11
+ fold,
12
+ canAssign,
13
+ aggregate,
14
+ expressionIssue,
15
+ defaultValue,
16
+ placeType,
17
+ durationLiteral,
18
+ calendarLiteral,
19
+ loopIterations,
20
+ loopBounds,
21
+ narrowRanges,
22
+ interval,
23
+ valueRange,
24
+ integerRange,
25
+ comparisonOperators,
26
+ type BinaryOperator,
27
+ type IntegerInterval,
28
+ type Constant,
29
+ type Diagnostic,
30
+ type Expression,
31
+ type PlcFunction,
32
+ type MethodOwner,
33
+ type PlcModule,
34
+ type PlcProgram,
35
+ type SourceSpan,
36
+ type Statement,
37
+ type Variable,
38
+ type Place,
39
+ } from "@tsplc/compiler/ir";
40
+
41
+ import { lowerExposure } from "./exposure.ts";
42
+ import { lowerServices, serviceRecords, type ServiceOptions } from "./services.ts";
43
+ import { lowerPromises } from "./promises.ts";
44
+ import { lowerAsync } from "./async.ts";
45
+ import { lowerConstFunctions } from "./const-functions.ts";
46
+ import { lowerElaboration } from "./elaborate/index.ts";
47
+ import { lowerEnvironment } from "./configuration.ts";
48
+ import { BlockResolver, storageInitializer, type BlockSource } from "./blocks.ts";
49
+ import { numericLiteralType } from "./types.ts";
50
+ import {
51
+ TypeResolver,
52
+ calleeName,
53
+ type Fail,
54
+ type TypeEnvironment,
55
+ type ResolvedType,
56
+ } from "./types.ts";
57
+
58
+ export interface FrontendOptions {
59
+ sourceScope?: "project" | "entry";
60
+ services?: ServiceOptions;
61
+ tsconfig: string;
62
+ programs: Readonly<Record<string, { module: string; export: string }>>;
63
+ }
64
+
65
+ export interface FrontendResult {
66
+ module?: PlcModule;
67
+ diagnostics: readonly Diagnostic[];
68
+ }
69
+
70
+ // TIME constructor scales in nanoseconds, and calendar constructor result types.
71
+ const durationScales: Readonly<Record<string, bigint>> = {
72
+ ms: 1_000_000n,
73
+ seconds: 1_000_000_000n,
74
+ minutes: 60_000_000_000n,
75
+ };
76
+ const calendarTypes: Readonly<Record<string, string>> = {
77
+ date: "DATE",
78
+ timeOfDay: "TIME_OF_DAY",
79
+ dateAndTime: "DATE_AND_TIME",
80
+ };
81
+ const conversions: Readonly<Record<string, "exact" | "wrap" | "saturate">> = {
82
+ exact: "exact",
83
+ wrap: "wrap",
84
+ saturate: "saturate",
85
+ };
86
+ const arithmetic: Readonly<
87
+ Record<
88
+ string,
89
+ { operator: "add" | "subtract" | "multiply" | "divide"; policy?: "wrap" | "saturate" }
90
+ >
91
+ > = {
92
+ div: { operator: "divide" },
93
+ wrapAdd: { operator: "add", policy: "wrap" },
94
+ wrapSubtract: { operator: "subtract", policy: "wrap" },
95
+ wrapMultiply: { operator: "multiply", policy: "wrap" },
96
+ saturatingAdd: { operator: "add", policy: "saturate" },
97
+ saturatingSubtract: { operator: "subtract", policy: "saturate" },
98
+ saturatingMultiply: { operator: "multiply", policy: "saturate" },
99
+ };
100
+ const operators = new Map<ts.SyntaxKind, BinaryOperator>([
101
+ [ts.SyntaxKind.AmpersandAmpersandToken, "and"],
102
+ [ts.SyntaxKind.BarBarToken, "or"],
103
+ [ts.SyntaxKind.EqualsEqualsEqualsToken, "equal"],
104
+ [ts.SyntaxKind.ExclamationEqualsEqualsToken, "notEqual"],
105
+ [ts.SyntaxKind.LessThanToken, "less"],
106
+ [ts.SyntaxKind.LessThanEqualsToken, "lessEqual"],
107
+ [ts.SyntaxKind.GreaterThanToken, "greater"],
108
+ [ts.SyntaxKind.GreaterThanEqualsToken, "greaterEqual"],
109
+ [ts.SyntaxKind.PlusToken, "add"],
110
+ [ts.SyntaxKind.MinusToken, "subtract"],
111
+ [ts.SyntaxKind.AsteriskToken, "multiply"],
112
+ [ts.SyntaxKind.SlashToken, "divide"],
113
+ [ts.SyntaxKind.PercentToken, "remainder"],
114
+ [ts.SyntaxKind.AmpersandToken, "bitAnd"],
115
+ [ts.SyntaxKind.BarToken, "bitOr"],
116
+ [ts.SyntaxKind.CaretToken, "bitXor"],
117
+ [ts.SyntaxKind.LessThanLessThanToken, "shiftLeft"],
118
+ [ts.SyntaxKind.GreaterThanGreaterThanToken, "shiftRight"],
119
+ ]);
120
+
121
+ class SemanticError extends Error {
122
+ constructor(readonly diagnostic: Diagnostic) {
123
+ super(diagnostic.message);
124
+ }
125
+ }
126
+
127
+ function sourceSpan(source: ts.SourceFile, start: number, end: number): SourceSpan {
128
+ const position = source.getLineAndCharacterOfPosition(start);
129
+ return {
130
+ file: source.fileName,
131
+ start,
132
+ end,
133
+ line: position.line + 1,
134
+ column: position.character + 1,
135
+ };
136
+ }
137
+
138
+ function span(node: ts.Node): SourceSpan {
139
+ const source = node.getSourceFile();
140
+ return sourceSpan(source, node.getStart(source), node.getEnd());
141
+ }
142
+
143
+ function tsDiagnostic(diagnostic: ts.Diagnostic): Diagnostic {
144
+ const result: Diagnostic = {
145
+ origin: "typescript",
146
+ code: `TS${diagnostic.code}`,
147
+ severity: diagnostic.category === ts.DiagnosticCategory.Error ? "error" : "warning",
148
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
149
+ };
150
+ if (diagnostic.file && diagnostic.start !== undefined)
151
+ result.span = sourceSpan(
152
+ diagnostic.file,
153
+ diagnostic.start,
154
+ diagnostic.start + (diagnostic.length ?? 1),
155
+ );
156
+ return result;
157
+ }
158
+
159
+ const suppressionDiagnostics = (source: ts.SourceFile): Diagnostic[] => {
160
+ const diagnostics: Diagnostic[] = [];
161
+ const scanner = ts.createScanner(
162
+ ts.ScriptTarget.Latest,
163
+ false,
164
+ ts.LanguageVariant.Standard,
165
+ source.text,
166
+ );
167
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
168
+ if (
169
+ token !== ts.SyntaxKind.SingleLineCommentTrivia &&
170
+ token !== ts.SyntaxKind.MultiLineCommentTrivia
171
+ )
172
+ continue;
173
+ const comment = scanner.getTokenText();
174
+ const match = /(?:^|\n)\s*(?:\/\/+|\/\*+|\*)\s*(@ts-(?:nocheck|ignore|expect-error))\b/.exec(
175
+ comment,
176
+ );
177
+ if (!match) continue;
178
+ const start = scanner.getTokenPos() + comment.indexOf(match[1] ?? "@ts-");
179
+ diagnostics.push(
180
+ error(
181
+ "PLC1009",
182
+ "TypeScript diagnostic suppression is not allowed in PLC source.",
183
+ sourceSpan(source, start, start + (match[1]?.length ?? 4)),
184
+ ),
185
+ );
186
+ }
187
+ return diagnostics;
188
+ };
189
+
190
+ /**
191
+ * Type checks a subset of a tsconfig's files without emitting. A project-reference
192
+ * tsconfig's composite and declaration settings would demand every file be listed
193
+ * (TS6307) or conflict with noEmit, so these replace them.
194
+ */
195
+ export const typeCheckOnly: ts.CompilerOptions = {
196
+ composite: false,
197
+ declaration: false,
198
+ declarationMap: false,
199
+ emitDeclarationOnly: false,
200
+ incremental: false,
201
+ noEmit: true,
202
+ tsBuildInfoFile: undefined,
203
+ };
204
+
205
+ export function analyze(options: FrontendOptions): FrontendResult {
206
+ const filename = resolve(options.tsconfig);
207
+ const config = ts.readConfigFile(filename, ts.sys.readFile);
208
+ if (config.error) return { diagnostics: [tsDiagnostic(config.error)] };
209
+ const parsed = ts.parseJsonConfigFileContent(
210
+ config.config,
211
+ ts.sys,
212
+ dirname(filename),
213
+ undefined,
214
+ filename,
215
+ );
216
+ if (parsed.errors.length) return { diagnostics: parsed.errors.map(tsDiagnostic) };
217
+ // TypeScript stores SourceFile paths with forward slashes on every host.
218
+ // Normalize require.resolve once so canonical SDK identity also works on Windows.
219
+ const sdk = createRequire(import.meta.url)
220
+ .resolve("@tsplc/compiler/globals")
221
+ .replaceAll("\\", "/");
222
+ const programFiles = Object.values(options.programs).map((entry) =>
223
+ resolve(dirname(filename), entry.module),
224
+ );
225
+ // Full-stack builds follow entry imports; standalone projects retain explicit tsconfig globals.
226
+ const scope = options.sourceScope ?? (options.services ? "entry" : "project");
227
+ // The project's own ambient declarations; a shared base tsconfig's typings are for the host runtime.
228
+ const ambient = parsed.fileNames.filter(
229
+ (file) => /\.d\.[cm]?ts$/.test(file) && file.startsWith(`${dirname(filename)}/`),
230
+ );
231
+ const projectFiles = scope === "project" ? parsed.fileNames : ambient;
232
+ const rootNames = [...new Set([...projectFiles, ...programFiles, sdk])];
233
+ const compilerOptions: ts.CompilerOptions = {
234
+ ...parsed.options,
235
+ ...typeCheckOnly,
236
+ // Lowered sources import their runtime helpers by .ts path and drop the imports they replace.
237
+ allowImportingTsExtensions: true,
238
+ noUnusedLocals: false,
239
+ noUnusedParameters: false,
240
+ strict: true,
241
+ noImplicitAny: true,
242
+ strictNullChecks: true,
243
+ noCheck: false,
244
+ allowJs: false,
245
+ skipLibCheck: false,
246
+ types: [],
247
+ lib: ["lib.es2022.d.ts"],
248
+ module: ts.ModuleKind.ESNext,
249
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
250
+ };
251
+ // Nothing is emitted, and a service client's router type imports modules outside these (TS6059).
252
+ delete compilerOptions.rootDir;
253
+ delete compilerOptions.outDir;
254
+ let program = ts.createProgram(rootNames, compilerOptions);
255
+ const plcFiles = (program: ts.Program) => {
256
+ const files = new Set(runtimeSources(program));
257
+ for (const source of program.getSourceFiles())
258
+ if (source.fileName.startsWith(dirname(sdk) + "/")) files.add(source);
259
+ return files;
260
+ };
261
+ // Only PLC files: a type-only import of the server router brings the server's sources along.
262
+ const sourceDiagnostics = (program: ts.Program) =>
263
+ [
264
+ ...program.getConfigFileParsingDiagnostics(),
265
+ ...program.getOptionsDiagnostics(),
266
+ ...program.getGlobalDiagnostics(),
267
+ ...[...plcFiles(program)].flatMap((file) => [
268
+ ...program.getSyntacticDiagnostics(file),
269
+ ...program.getSemanticDiagnostics(file),
270
+ ]),
271
+ ].map(tsDiagnostic);
272
+ const diagnostics = sourceDiagnostics(program);
273
+ if (
274
+ program.getSyntacticDiagnostics().some((item) => !item.file || plcFiles(program).has(item.file))
275
+ )
276
+ return { diagnostics };
277
+ const virtualSources = new Map<string, string>();
278
+ // Lowering also adds modules, such as the service client, that exist only here.
279
+ let host: ts.CompilerHost = ts.createCompilerHost(compilerOptions);
280
+ const apply = (lowering: {
281
+ sources: Map<string, string>;
282
+ diagnostics: Diagnostic[];
283
+ }): boolean => {
284
+ diagnostics.push(...lowering.diagnostics);
285
+ for (const source of runtimeSources(program))
286
+ if (lowering.sources.has(source.fileName))
287
+ diagnostics.push(...suppressionDiagnostics(source));
288
+ if (lowering.diagnostics.length || (lowering.sources.size && hasErrors(diagnostics)))
289
+ return false;
290
+ if (lowering.sources.size) {
291
+ for (const [path, text] of lowering.sources) virtualSources.set(path, text);
292
+ // TSPLC_LOWERED=<directory> writes every generated or rewritten module, as the next step reads it.
293
+ const dump = process.env.TSPLC_LOWERED;
294
+ if (dump)
295
+ for (const [path, text] of lowering.sources) {
296
+ const target = join(dump, relative(dirname(filename), path));
297
+ mkdirSync(dirname(target), { recursive: true });
298
+ writeFileSync(target, text);
299
+ }
300
+ host = ts.createCompilerHost(compilerOptions);
301
+ const read = host.readFile,
302
+ exists = host.fileExists;
303
+ const directoryExists = host.directoryExists?.bind(host);
304
+ host.readFile = (path) => virtualSources.get(path) ?? read(path);
305
+ host.fileExists = (path) => virtualSources.has(path) || exists(path);
306
+ // Generated modules can live in a directory that only exists in memory.
307
+ host.directoryExists = (path) =>
308
+ [...virtualSources.keys()].some((file) => file.startsWith(`${path.replace(/\/$/, "")}/`)) ||
309
+ (directoryExists?.(path) ?? true);
310
+ program = ts.createProgram(rootNames, compilerOptions, host);
311
+ diagnostics.push(...sourceDiagnostics(program));
312
+ }
313
+ return true;
314
+ };
315
+ if (!apply(lowerElaboration(program, sdk, options.programs, dirname(filename))))
316
+ return { diagnostics };
317
+ const services = lowerServices(program, sdk, options.services);
318
+ if (!apply(services)) return { diagnostics };
319
+ const exposure = lowerExposure(program, sdk);
320
+ if (!apply(exposure)) return { diagnostics };
321
+ if (!apply(lowerEnvironment(program, sdk))) return { diagnostics };
322
+ if (!apply(lowerConstFunctions(program))) return { diagnostics };
323
+ const promises = lowerPromises(program, sdk);
324
+ if (!apply(promises)) return { diagnostics };
325
+ const lowering = lowerAsync(program, sdk);
326
+ for (const [key, value] of promises.runtimeBlocks) lowering.blocks.set(key, value);
327
+ if (!apply(lowering)) return { diagnostics };
328
+ const analyzer = new Analyzer(
329
+ program,
330
+ host,
331
+ dirname(filename),
332
+ sdk,
333
+ diagnostics,
334
+ serviceRecords(program, services),
335
+ );
336
+ const module = analyzer.run({ ...options.programs, ...lowering.programs });
337
+ for (const variable of module.variables) {
338
+ const item = exposure.variables.get(`${variable.span.file}:${variable.name}`);
339
+ if (item) variable.exposure = item;
340
+ }
341
+ for (const block of module.blocks) {
342
+ const runtime = lowering.blocks.get(`${block.span.file}:${block.name}`);
343
+ if (runtime) block.runtime = runtime;
344
+ }
345
+ for (const entry of module.programs) {
346
+ const background = lowering.schedules.get(entry.name);
347
+ if (background) entry.background = background;
348
+ const continuation = lowering.continuations.get(entry.name);
349
+ if (continuation) entry.continuation = continuation;
350
+ }
351
+ return hasErrors(diagnostics) ? { diagnostics } : { module, diagnostics };
352
+ }
353
+
354
+ export function checkHostConfig(file: string): readonly Diagnostic[] {
355
+ const bunTypes = createRequire(import.meta.url).resolve("@types/bun/index.d.ts");
356
+ const program = ts.createProgram([file], {
357
+ strict: true,
358
+ noEmit: true,
359
+ target: ts.ScriptTarget.ES2022,
360
+ module: ts.ModuleKind.ESNext,
361
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
362
+ allowImportingTsExtensions: true,
363
+ skipLibCheck: true,
364
+ types: [bunTypes],
365
+ });
366
+ return ts.getPreEmitDiagnostics(program).map(tsDiagnostic);
367
+ }
368
+
369
+ class Analyzer {
370
+ private readonly checker: ts.TypeChecker;
371
+ private readonly variables = new Map<ts.Symbol, Variable>();
372
+ private readonly functions = new Map<ts.Symbol, PlcFunction>();
373
+ private readonly methods = new Map<ts.Symbol, PlcFunction>();
374
+ private readonly functionDeclarations = new Map<ts.Symbol, ts.FunctionDeclaration>();
375
+ private readonly instances = new Map<string, PlcFunction>();
376
+ private readonly pending: {
377
+ node: ts.FunctionDeclaration | ts.MethodDeclaration;
378
+ fn: PlcFunction;
379
+ environment: TypeEnvironment;
380
+ }[] = [];
381
+ private readonly resolver: TypeResolver;
382
+ private readonly blocks: BlockResolver;
383
+ private readonly globalVariables: Variable[] = [];
384
+ private readonly sources: readonly ts.SourceFile[];
385
+
386
+ // This scope is saved and restored for each block or generic function body.
387
+ private environment: TypeEnvironment = new Map();
388
+ private activeBlock: BlockSource | undefined;
389
+ private readonly ranges = new Map<string, IntegerInterval>();
390
+ private readonly loopCounters = new Set<string>();
391
+ private readonly aliases = new Map<ts.Symbol, Place>();
392
+ private copySources: Place[] = [];
393
+ private readonly breakTargets: ("loop" | "switch")[] = [];
394
+ private activeFunction: PlcFunction | undefined;
395
+
396
+ constructor(
397
+ private readonly program: ts.Program,
398
+ private readonly host: ts.ModuleResolutionHost,
399
+ private readonly root: string,
400
+ private readonly sdk: string,
401
+ private readonly diagnostics: Diagnostic[],
402
+ serviceRecord?: (type: ts.Type) => ts.Symbol | undefined,
403
+ ) {
404
+ this.checker = program.getTypeChecker();
405
+ this.resolver = new TypeResolver(this.checker, root, sdk, this.fail, serviceRecord);
406
+ this.blocks = new BlockResolver(
407
+ this.resolver,
408
+ span,
409
+ this.fail,
410
+ span(program.getSourceFile(sdk)!),
411
+ );
412
+ const roots = new Set(program.getRootFileNames());
413
+ this.sources = runtimeSources(program).filter(
414
+ (source) =>
415
+ source.fileName !== sdk &&
416
+ !source.fileName.startsWith(`${dirname(sdk)}/`) &&
417
+ !program.isSourceFileDefaultLibrary(source) &&
418
+ (!source.isDeclarationFile || roots.has(source.fileName)),
419
+ );
420
+ }
421
+
422
+ private readonly fail: Fail = (node, code, message) => {
423
+ throw new SemanticError(error(code, message, span(node)));
424
+ };
425
+
426
+ private type(node: ts.TypeNode): ResolvedType {
427
+ return this.resolver.resolve(node, this.environment);
428
+ }
429
+
430
+ private storageId(node: ts.Node, name: string): string {
431
+ return `${this.resolver.id(node, name)}${this.activeFunction ? `@${this.activeFunction.id}` : ""}`;
432
+ }
433
+
434
+ private guard(action: () => void): void {
435
+ try {
436
+ action();
437
+ } catch (cause) {
438
+ if (!(cause instanceof SemanticError)) throw cause;
439
+ this.diagnostics.push(cause.diagnostic);
440
+ }
441
+ }
442
+
443
+ run(entries: FrontendOptions["programs"]): PlcModule {
444
+ // Collect every declaration before resolving initializers and routine bodies.
445
+ // Generic calls can enqueue more helpers, so resolve entry points last.
446
+ this.collectDeclarations();
447
+ this.initializeStorage();
448
+ this.analyzeBlockBodies();
449
+ this.analyzePendingFunctions();
450
+ const programs = this.resolvePrograms(entries);
451
+
452
+ return {
453
+ types: [...this.resolver.types.values()],
454
+ variables: this.globalVariables,
455
+ functions: [...this.instances.values()],
456
+ blocks: [...this.blocks.definitions.values()],
457
+ programs,
458
+ };
459
+ }
460
+
461
+ private collectDeclarations(): void {
462
+ const classes = this.sources.flatMap((source) =>
463
+ source.statements.filter(ts.isClassDeclaration),
464
+ );
465
+ this.blocks.index(classes);
466
+ for (const node of classes) this.guard(() => this.blocks.collect(node));
467
+ for (const source of this.blocks.sources.values()) {
468
+ for (const method of source.methods)
469
+ this.guard(() => {
470
+ const fn = this.instantiate(method, [], method);
471
+ fn.owner = {
472
+ block: source.block.id,
473
+ access: this.methodAccess(method),
474
+ };
475
+ this.methods.set(this.resolver.symbol(method.name), fn);
476
+ });
477
+ }
478
+ for (const source of this.sources) {
479
+ this.diagnostics.push(...suppressionDiagnostics(source));
480
+ for (const statement of source.statements) {
481
+ this.guard(() => this.declaration(statement));
482
+ }
483
+ }
484
+ }
485
+
486
+ private methodAccess(method: ts.MethodDeclaration): MethodOwner["access"] {
487
+ if (method.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword))
488
+ return "private";
489
+ if (method.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ProtectedKeyword))
490
+ return "protected";
491
+ return "public";
492
+ }
493
+
494
+ private initializeStorage(): void {
495
+ for (const source of this.sources) {
496
+ for (const statement of source.statements) {
497
+ if (ts.isVariableStatement(statement) && !source.isDeclarationFile) {
498
+ this.guard(() => {
499
+ for (const declaration of statement.declarationList.declarations) {
500
+ if (!declaration.initializer)
501
+ this.fail(
502
+ declaration,
503
+ "PLC3001",
504
+ "Persistent PLC state requires a constant initializer.",
505
+ );
506
+ const variable = this.variables.get(this.resolver.symbol(declaration.name));
507
+ if (!variable || variable.storage === "instance") continue;
508
+ const initializer = storageInitializer(
509
+ declaration.initializer,
510
+ this.resolver,
511
+ this.fail,
512
+ ).value;
513
+ const inferred = variable.type === "inferred";
514
+ const expression = this.expression(initializer, inferred ? undefined : variable.type);
515
+ variable.type = expression.type;
516
+ if (
517
+ inferred &&
518
+ variable.storage === "constant" &&
519
+ aggregate(this.resolver.types.get(variable.type))
520
+ )
521
+ variable.storage = "module";
522
+ if (variable.retain && variable.storage === "constant")
523
+ this.fail(
524
+ declaration,
525
+ "PLC3004",
526
+ "retain requires mutable persistent storage or a static block instance.",
527
+ );
528
+ const value = this.constant(this.implicitCopy(expression));
529
+ if (!value)
530
+ this.fail(
531
+ declaration.initializer,
532
+ "PLC3001",
533
+ "Persistent initializers must be compile-time constants.",
534
+ );
535
+ variable.initial = value;
536
+ }
537
+ });
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ private analyzeBlockBodies(): void {
544
+ for (const source of this.blocks.sources.values())
545
+ this.guard(() => {
546
+ for (const [variable, node] of source.initializers) {
547
+ const value = this.constant(this.expression(node, variable.type));
548
+ if (!value)
549
+ this.fail(node, "PLC3001", "Block state requires a compile-time constant initializer.");
550
+ variable.initial = value;
551
+ }
552
+ const { block } = source;
553
+ this.activeBlock = source;
554
+ try {
555
+ const routine = this.routineBody(
556
+ {
557
+ id: block.id,
558
+ name: block.name,
559
+ parameters: block.inputs,
560
+ locals: [],
561
+ body: [],
562
+ span: block.span,
563
+ ...(block.result && { result: block.result }),
564
+ },
565
+ source.fieldBindings,
566
+ source.method.body!,
567
+ );
568
+ block.locals = routine.locals;
569
+ block.body = routine.body;
570
+ } finally {
571
+ this.activeBlock = undefined;
572
+ }
573
+ });
574
+ }
575
+
576
+ private analyzePendingFunctions(): void {
577
+ // A worklist allows explicit generic instances to share source without sharing locals.
578
+ for (let index = 0; index < this.pending.length; index++) {
579
+ const instance = this.pending[index]!;
580
+ this.guard(() => {
581
+ this.environment = instance.environment;
582
+ this.activeBlock = instance.fn.owner
583
+ ? [...this.blocks.sources.values()].find(
584
+ (source) => source.block.id === instance.fn.owner?.block,
585
+ )
586
+ : undefined;
587
+ try {
588
+ // Declaration admission proved the parameter list and body before worklist analysis.
589
+ const fn = this.routineBody(
590
+ instance.fn,
591
+ [
592
+ ...(this.activeBlock?.fieldBindings ?? []),
593
+ ...instance.node.parameters.map(
594
+ (parameter, index) =>
595
+ [this.resolver.symbol(parameter.name), instance.fn.parameters[index]!] as const,
596
+ ),
597
+ ],
598
+ instance.node.body!,
599
+ );
600
+ this.instances.set(fn.id, fn);
601
+ if (ts.isFunctionDeclaration(instance.node) && !instance.node.typeParameters?.length)
602
+ this.functions.set(this.resolver.symbol(instance.node.name!), fn);
603
+ } finally {
604
+ this.environment = new Map();
605
+ this.activeBlock = undefined;
606
+ }
607
+ });
608
+ }
609
+ }
610
+
611
+ // Lowers one routine body with its bindings layered over module storage.
612
+ private routineBody(
613
+ fn: PlcFunction,
614
+ bindings: Iterable<readonly [ts.Symbol, Variable]>,
615
+ body: ts.Block,
616
+ ): PlcFunction {
617
+ const saved = new Map(this.variables),
618
+ locals: Variable[] = [];
619
+ this.activeFunction = fn;
620
+ this.copySources = [];
621
+ for (const [symbol, variable] of bindings) this.variables.set(symbol, variable);
622
+ try {
623
+ return { ...fn, locals, body: this.statements(body.statements, locals) };
624
+ } finally {
625
+ this.variables.clear();
626
+ for (const [symbol, variable] of saved) this.variables.set(symbol, variable);
627
+ this.activeFunction = undefined;
628
+ }
629
+ }
630
+
631
+ private resolvePrograms(entries: FrontendOptions["programs"]): PlcProgram[] {
632
+ const programs: PlcProgram[] = [];
633
+ for (const [name, entry] of Object.entries(entries)) {
634
+ const source = this.program.getSourceFile(resolve(this.root, entry.module));
635
+ if (!source) {
636
+ this.diagnostics.push(error("PLC4001", `Program source not found: ${entry.module}.`));
637
+ continue;
638
+ }
639
+ this.guard(() => {
640
+ const moduleSymbol = this.checker.getSymbolAtLocation(source);
641
+ const exported =
642
+ moduleSymbol &&
643
+ this.checker
644
+ .getExportsOfModule(moduleSymbol)
645
+ .find((symbol) => symbol.name === entry.export);
646
+ if (!exported)
647
+ this.fail(source, "PLC4001", `Program export ${entry.export} was not found.`);
648
+ const symbol =
649
+ exported.flags & ts.SymbolFlags.Alias
650
+ ? this.checker.getAliasedSymbol(exported)
651
+ : exported;
652
+ const fn = this.functions.get(symbol);
653
+ if (!fn || fn.parameters.length || fn.result)
654
+ this.fail(
655
+ source,
656
+ "PLC4001",
657
+ "Program entry must be an implemented parameterless void function.",
658
+ );
659
+ programs.push({ id: `program:${name}`, name, entry: fn.id });
660
+ });
661
+ }
662
+ return programs;
663
+ }
664
+
665
+ private dependency(node: ts.ImportDeclaration | ts.ExportDeclaration): void {
666
+ if (!node.moduleSpecifier) return;
667
+ if (!ts.isStringLiteral(node.moduleSpecifier))
668
+ this.fail(node, "PLC1001", "Only static module imports are supported.");
669
+ const resolved = ts.resolveModuleName(
670
+ node.moduleSpecifier.text,
671
+ node.getSourceFile().fileName,
672
+ this.program.getCompilerOptions(),
673
+ this.host,
674
+ ).resolvedModule;
675
+ if (!resolved)
676
+ this.fail(
677
+ node.moduleSpecifier,
678
+ "PLC1008",
679
+ "This dependency could not be resolved to PLC source.",
680
+ );
681
+ const source = this.program.getSourceFile(resolved.resolvedFileName);
682
+ const typeOnly = ts.isImportDeclaration(node) ? node.importClause?.isTypeOnly : node.isTypeOnly;
683
+ if (typeOnly) return;
684
+ const bindings = ts.isImportDeclaration(node)
685
+ ? node.importClause?.namedBindings
686
+ : node.exportClause;
687
+ const defaultName = ts.isImportDeclaration(node) ? node.importClause?.name : undefined;
688
+ const namedOnly =
689
+ bindings &&
690
+ (ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) &&
691
+ bindings.elements.length > 0 &&
692
+ bindings.elements.every(
693
+ (binding) =>
694
+ binding.isTypeOnly || !(this.resolver.symbol(binding.name).flags & ts.SymbolFlags.Value),
695
+ );
696
+ if (namedOnly && !defaultName) return;
697
+ if (source && !source.isDeclarationFile) return;
698
+ if (resolved.resolvedFileName.replaceAll("\\", "/").startsWith(`${dirname(this.sdk)}/`)) return;
699
+ const authoredIo =
700
+ source &&
701
+ this.sources.includes(source) &&
702
+ source.statements.some(ts.isVariableStatement) &&
703
+ source.statements.every(
704
+ (statement) =>
705
+ ts.isTypeAliasDeclaration(statement) ||
706
+ ts.isEmptyStatement(statement) ||
707
+ (ts.isVariableStatement(statement) &&
708
+ statement.declarationList.declarations.every(
709
+ (declaration) => declaration.type && this.type(declaration.type).io,
710
+ )),
711
+ );
712
+ if (authoredIo) return;
713
+ this.fail(
714
+ node.moduleSpecifier,
715
+ "PLC1008",
716
+ "Runtime dependencies need PLC TypeScript implementation source; JavaScript and declaration-only value imports are unsupported.",
717
+ );
718
+ }
719
+
720
+ private declaration(statement: ts.Statement): void {
721
+ if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) {
722
+ this.dependency(statement);
723
+ return;
724
+ }
725
+ if (
726
+ ts.isClassDeclaration(statement) ||
727
+ ts.isTypeAliasDeclaration(statement) ||
728
+ ts.isInterfaceDeclaration(statement) ||
729
+ ts.isEmptyStatement(statement)
730
+ )
731
+ return;
732
+ if (ts.isEnumDeclaration(statement)) {
733
+ this.resolver.enumeration(statement);
734
+ return;
735
+ }
736
+ if (ts.isVariableStatement(statement)) {
737
+ this.moduleVariables(statement);
738
+ return;
739
+ }
740
+ if (ts.isFunctionDeclaration(statement)) {
741
+ if (!statement.name || !statement.body)
742
+ this.fail(statement, "PLC1001", "PLC functions need a name and an implementation.");
743
+ if (statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) {
744
+ this.fail(statement, "PLC1007", "Async execution is not supported in PLC scan code.");
745
+ }
746
+ if (statement.asteriskToken)
747
+ this.fail(statement, "PLC1007", "Generators are not supported in PLC scan code.");
748
+ const symbol = this.resolver.symbol(statement.name);
749
+ this.functionDeclarations.set(symbol, statement);
750
+ if (!statement.typeParameters?.length)
751
+ this.functions.set(symbol, this.instantiate(statement, [], statement));
752
+ return;
753
+ }
754
+ this.fail(
755
+ statement,
756
+ "PLC1001",
757
+ `${ts.SyntaxKind[statement.kind]} is not supported in PLC source.`,
758
+ );
759
+ }
760
+
761
+ private moduleVariables(statement: ts.VariableStatement): void {
762
+ if (!(statement.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)))
763
+ this.fail(statement, "PLC1001", "Use let or const instead of var.");
764
+ const immutable = Boolean(statement.declarationList.flags & ts.NodeFlags.Const);
765
+ for (const declaration of statement.declarationList.declarations) {
766
+ if (!ts.isIdentifier(declaration.name))
767
+ this.fail(declaration.name, "PLC1001", "Destructuring declarations are not supported.");
768
+ const initializer =
769
+ declaration.initializer &&
770
+ storageInitializer(declaration.initializer, this.resolver, this.fail);
771
+ const table = initializer && this.tableType(declaration, initializer.value);
772
+ const instance =
773
+ initializer && ts.isNewExpression(initializer.value)
774
+ ? this.blocks.instanceType(initializer.value)
775
+ : initializer && !table
776
+ ? this.blocks.instanceArrayType(declaration.type, initializer.value)
777
+ : undefined;
778
+ const literal = initializer && this.numericLiteral(initializer.value);
779
+ if (table && !immutable)
780
+ this.fail(declaration, "PLC2001", "A readonly table is a const declaration.");
781
+ const type = instance
782
+ ? { type: instance }
783
+ : (table ??
784
+ (declaration.type
785
+ ? this.type(declaration.type)
786
+ : { type: immutable ? "inferred" : literal ? numericLiteralType(literal) : "BOOL" }));
787
+ if (
788
+ !instance &&
789
+ !declaration.type &&
790
+ !immutable &&
791
+ !literal &&
792
+ declaration.initializer &&
793
+ initializer?.value.kind !== ts.SyntaxKind.TrueKeyword &&
794
+ initializer?.value.kind !== ts.SyntaxKind.FalseKeyword
795
+ )
796
+ this.fail(declaration, "PLC2001", "Annotate PLC storage explicitly.");
797
+ const ambient = declaration.getSourceFile().isDeclarationFile;
798
+ if (type.mode || this.resolver.hasReferences(type.type))
799
+ this.fail(declaration, "PLC2001", "Borrow schemas cannot be stored as values.");
800
+ if (this.resolver.types.get(type.type)?.kind === "block" && !instance)
801
+ this.fail(
802
+ declaration,
803
+ "PLC1007",
804
+ "Block instances require a static constructor initializer.",
805
+ );
806
+ if (
807
+ instance &&
808
+ (!immutable || (declaration.type && this.type(declaration.type).type !== instance))
809
+ )
810
+ this.fail(
811
+ declaration,
812
+ "PLC2001",
813
+ "Static block instances require const bindings and matching annotations.",
814
+ );
815
+ if (
816
+ initializer?.retain &&
817
+ (ambient ||
818
+ (immutable &&
819
+ type.type !== "inferred" &&
820
+ !instance &&
821
+ !aggregate(this.resolver.types.get(type.type))))
822
+ )
823
+ this.fail(
824
+ declaration,
825
+ "PLC3004",
826
+ "retain requires mutable persistent storage or a static block instance.",
827
+ );
828
+ if (ambient && !type.io)
829
+ this.fail(declaration, "PLC4002", "Ambient PLC variables must declare Input or Output.");
830
+ if (!ambient && type.io)
831
+ this.fail(declaration, "PLC4002", "Logical I/O belongs in an ambient declaration file.");
832
+ if (type.io === "input" && !immutable)
833
+ this.fail(declaration, "PLC4002", "Inputs must use declare const.");
834
+ if (type.io === "output" && immutable)
835
+ this.fail(declaration, "PLC4002", "Outputs must use declare let.");
836
+ const variable: Variable = {
837
+ id: this.storageId(declaration, declaration.name.text),
838
+ name: declaration.name.text,
839
+ type: type.type,
840
+ storage: instance
841
+ ? "instance"
842
+ : (type.io ??
843
+ (immutable && (table || !aggregate(this.resolver.types.get(type.type)))
844
+ ? "constant"
845
+ : "module")),
846
+ mutable: !immutable,
847
+ retain: initializer?.retain ?? false,
848
+ ...(statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
849
+ ? { exported: true as const }
850
+ : {}),
851
+ span: span(declaration),
852
+ };
853
+ this.variables.set(this.resolver.symbol(declaration.name), variable);
854
+ this.globalVariables.push(variable);
855
+ }
856
+ }
857
+
858
+ /**
859
+ * `const table: readonly T[] = [...]` or `const table = [...] as const satisfies readonly T[]`,
860
+ * also spelled `ReadonlyArray<T>`, is a constant `FixedArray<T, N>` whose length is the
861
+ * literal's element count.
862
+ */
863
+ private tableType(
864
+ declaration: ts.VariableDeclaration,
865
+ initializer: ts.Expression,
866
+ ): ResolvedType | undefined {
867
+ const satisfied = ts.isSatisfiesExpression(initializer) ? initializer : undefined;
868
+ const annotation = declaration.type ?? satisfied?.type;
869
+ if (!annotation) return undefined;
870
+ let element: ts.TypeNode | undefined;
871
+ if (
872
+ ts.isTypeOperatorNode(annotation) &&
873
+ annotation.operator === ts.SyntaxKind.ReadonlyKeyword &&
874
+ ts.isArrayTypeNode(annotation.type)
875
+ )
876
+ element = annotation.type.elementType;
877
+ else if (
878
+ ts.isTypeReferenceNode(annotation) &&
879
+ annotation.typeName.getText() === "ReadonlyArray" &&
880
+ annotation.typeArguments?.length === 1 &&
881
+ this.resolver.standardLibrary(this.resolver.symbol(annotation.typeName))
882
+ )
883
+ element = annotation.typeArguments[0];
884
+ if (!element) return undefined;
885
+ let literal = satisfied?.expression ?? initializer;
886
+ while (
887
+ ts.isParenthesizedExpression(literal) ||
888
+ (ts.isAsExpression(literal) && ts.isConstTypeReference(literal.type))
889
+ )
890
+ literal = literal.expression;
891
+ if (!ts.isArrayLiteralExpression(literal))
892
+ this.fail(declaration, "PLC2001", "A readonly table is initialized by an array literal.");
893
+ return this.resolver.fixedArray(
894
+ annotation,
895
+ this.type(element),
896
+ { kind: "integer", value: literal.elements.length },
897
+ { kind: "integer", value: 0 },
898
+ );
899
+ }
900
+
901
+ private instantiate(
902
+ node: ts.FunctionDeclaration | ts.MethodDeclaration,
903
+ arguments_: readonly ts.TypeNode[],
904
+ call: ts.Node,
905
+ ): PlcFunction {
906
+ if (!node.name || !ts.isIdentifier(node.name))
907
+ this.fail(node, "PLC1001", "PLC routines need identifier names.");
908
+ const routineName = node.name.text;
909
+ const environment = this.resolver.bindings(
910
+ node.typeParameters,
911
+ arguments_,
912
+ this.environment,
913
+ call,
914
+ );
915
+ const suffix = (node.typeParameters ?? [])
916
+ .map((parameter) => this.resolver.key(environment.get(this.resolver.symbol(parameter.name))!))
917
+ .join(",");
918
+ const id = `${this.resolver.id(node, routineName)}${suffix ? `<${suffix}>` : ""}`;
919
+ const existing = this.instances.get(id);
920
+ if (existing) return existing;
921
+ // Bounds generic expansion; plain helpers each instantiate once.
922
+ if (suffix && [...this.instances.keys()].filter((key) => key.endsWith(">")).length >= 256)
923
+ this.fail(
924
+ call,
925
+ "PLC2001",
926
+ "Generic specialization exceeds 256 concrete helpers; recursive specialization is unsupported.",
927
+ );
928
+ const saved = this.environment;
929
+ this.environment = environment;
930
+ try {
931
+ const parameters = node.parameters.map((parameter) => {
932
+ if (
933
+ !ts.isIdentifier(parameter.name) ||
934
+ !parameter.type ||
935
+ parameter.initializer ||
936
+ parameter.questionToken ||
937
+ parameter.dotDotDotToken
938
+ )
939
+ this.fail(
940
+ parameter,
941
+ "PLC2001",
942
+ "PLC parameters need names and explicit types; optional/rest/default parameters are unsupported.",
943
+ );
944
+ const type = this.type(parameter.type);
945
+ const block = this.resolver.types.get(type.type)?.kind === "block";
946
+ if (type.mode !== "inOut" && !block && !this.resolver.isValue(type))
947
+ this.fail(parameter, "PLC2001", "Parameters require value types or InOut borrows.");
948
+ if (type.io) this.fail(parameter, "PLC2001", "Parameters cannot use logical I/O wrappers.");
949
+ // Records, arrays and blocks pass by reference, as objects do in TypeScript.
950
+ const mode =
951
+ type.mode ??
952
+ (block || aggregate(this.resolver.types.get(type.type)) ? "inOut" : undefined);
953
+ return {
954
+ id: `${this.resolver.id(parameter, parameter.name.text)}@${id}`,
955
+ name: parameter.name.text,
956
+ type: type.type,
957
+ storage: "parameter" as const,
958
+ mutable: mode === "inOut",
959
+ ...(mode && { mode }),
960
+ retain: false,
961
+ span: span(parameter),
962
+ };
963
+ });
964
+ let result: string | undefined;
965
+ if (node.type && node.type.kind !== ts.SyntaxKind.VoidKeyword) {
966
+ const type = this.type(node.type);
967
+ if (!this.resolver.isValue(type))
968
+ this.fail(node.type, "PLC2001", "Helper results require value types.");
969
+ if (type.io) this.fail(node.type, "PLC4002", "Helper results cannot use I/O wrappers.");
970
+ result = type.type;
971
+ } else if (!node.type) {
972
+ const signature = this.checker.getSignatureFromDeclaration(node);
973
+ if (
974
+ !signature ||
975
+ !(this.checker.getReturnTypeOfSignature(signature).flags & ts.TypeFlags.Void)
976
+ )
977
+ this.fail(node, "PLC2001", "Annotate helper results explicitly.");
978
+ }
979
+ const bounds = this.resolver.mathHelper(node, routineName);
980
+ const fn: PlcFunction = {
981
+ id,
982
+ name: `${routineName}${suffix ? `_${suffix}` : ""}`,
983
+ parameters,
984
+ locals: [],
985
+ body: [],
986
+ span: span(node),
987
+ ...(result && { result }),
988
+ ...(bounds && { bounds }),
989
+ };
990
+ this.instances.set(id, fn);
991
+ this.pending.push({ node, fn, environment });
992
+ return fn;
993
+ } finally {
994
+ this.environment = saved;
995
+ }
996
+ }
997
+
998
+ private byId(): Map<string, Variable> {
999
+ return new Map(
1000
+ [
1001
+ ...this.variables.values(),
1002
+ ...(this.activeBlock
1003
+ ? [...this.activeBlock.block.inputs, ...this.activeBlock.block.outputs]
1004
+ : []),
1005
+ ].map((variable) => [variable.id, variable]),
1006
+ );
1007
+ }
1008
+
1009
+ private checked(value: Expression): Expression {
1010
+ const message = expressionIssue(value, this.resolver.types, this.byId(), this.ranges);
1011
+ if (message) throw new SemanticError(error("PLC2002", message, value.span));
1012
+ return value;
1013
+ }
1014
+
1015
+ private coerce(value: Expression, destination: string): Expression {
1016
+ if (value.type === destination) return value;
1017
+ const source = this.resolver.types.get(value.type),
1018
+ target = this.resolver.types.get(destination);
1019
+ if (!source || !target)
1020
+ throw new SemanticError(error("PLC2001", "This PLC type has not been resolved.", value.span));
1021
+ const constant = fold(value, this.byId(), this.resolver.types);
1022
+ if (
1023
+ source.kind === "string" &&
1024
+ target.kind === "string" &&
1025
+ source.encoding === target.encoding &&
1026
+ source.capacity <= target.capacity
1027
+ )
1028
+ return constant
1029
+ ? { kind: "constant", type: destination, value: constant, span: value.span }
1030
+ : { kind: "convert", type: destination, operand: value, policy: "exact", span: value.span };
1031
+ if (!canAssign(source, target, constant))
1032
+ throw new SemanticError(
1033
+ error(
1034
+ "PLC2002",
1035
+ "Use an explicit conversion between these PLC domains or widths.",
1036
+ value.span,
1037
+ ),
1038
+ );
1039
+ if (constant) return { kind: "constant", type: destination, value: constant, span: value.span };
1040
+ return this.checked({
1041
+ kind: "convert",
1042
+ type: destination,
1043
+ operand: value,
1044
+ policy: "exact",
1045
+ span: value.span,
1046
+ });
1047
+ }
1048
+
1049
+ private constant(expression: Expression): Constant | undefined {
1050
+ return fold(expression, this.byId(), this.resolver.types);
1051
+ }
1052
+
1053
+ private numericLiteral(node: ts.Expression): string | undefined {
1054
+ while (ts.isParenthesizedExpression(node)) node = node.expression;
1055
+ if (ts.isNumericLiteral(node) || ts.isBigIntLiteral(node)) return node.getText();
1056
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
1057
+ const operand = this.numericLiteral(node.operand);
1058
+ if (operand !== undefined) return operand.startsWith("-") ? operand.slice(1) : `-${operand}`;
1059
+ }
1060
+ return undefined;
1061
+ }
1062
+
1063
+ private contextualLiteral(node: ts.Expression): boolean {
1064
+ while (ts.isParenthesizedExpression(node)) node = node.expression;
1065
+ return (
1066
+ this.numericLiteral(node) !== undefined ||
1067
+ ts.isStringLiteral(node) ||
1068
+ ts.isNoSubstitutionTemplateLiteral(node)
1069
+ );
1070
+ }
1071
+
1072
+ private literal(node: ts.Expression, expected?: string): Expression | undefined {
1073
+ const text = this.numericLiteral(node);
1074
+ if (text === undefined) return undefined;
1075
+ const type = expected ?? numericLiteralType(text);
1076
+ const target = this.resolver.types.get(type);
1077
+ if (!target || !["integer", "bits", "float"].includes(target.kind))
1078
+ this.fail(
1079
+ node,
1080
+ "PLC2001",
1081
+ "Numeric literals require an arithmetic or bit-string PLC type; use named enum members.",
1082
+ );
1083
+ let value: Constant;
1084
+ if (target.kind === "float") {
1085
+ const clean = text.replaceAll("_", "");
1086
+ const number = /^-?0[xob]/i.test(clean) ? Number(parseInteger(clean)) : Number(clean);
1087
+ value = { kind: "float", value: target.bits === 32 ? Math.fround(number) : number };
1088
+ } else {
1089
+ try {
1090
+ value = { kind: "integer", value: String(parseInteger(text)) };
1091
+ } catch (cause) {
1092
+ this.fail(
1093
+ node,
1094
+ "PLC2002",
1095
+ cause instanceof Error ? cause.message : "Invalid integer literal.",
1096
+ );
1097
+ }
1098
+ }
1099
+ return this.checked({ kind: "constant", type, value, span: span(node) });
1100
+ }
1101
+
1102
+ private expression(node: ts.Expression, expected?: string): Expression {
1103
+ if (
1104
+ ts.isParenthesizedExpression(node) ||
1105
+ ts.isSatisfiesExpression(node) ||
1106
+ (ts.isAsExpression(node) && ts.isConstTypeReference(node.type))
1107
+ )
1108
+ return this.expression(node.expression, expected);
1109
+ const literal = this.literal(node, expected);
1110
+ if (literal) return literal;
1111
+ const value = this.rawExpression(node, expected);
1112
+ return expected ? this.coerce(value, expected) : value;
1113
+ }
1114
+
1115
+ private rawExpression(node: ts.Expression, expected?: string): Expression {
1116
+ const origin = { span: span(node) };
1117
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
1118
+ return {
1119
+ ...origin,
1120
+ type: "BOOL",
1121
+ kind: "constant",
1122
+ value: { kind: "bool", value: node.kind === ts.SyntaxKind.TrueKeyword },
1123
+ };
1124
+ }
1125
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1126
+ const type = expected ? this.resolver.types.get(expected) : undefined;
1127
+ if (type?.kind === "enum") {
1128
+ const member = type.members.find((member) => member.name === node.text);
1129
+ if (member)
1130
+ return {
1131
+ ...origin,
1132
+ type: type.id,
1133
+ kind: "constant",
1134
+ value: { kind: "integer", value: member.value },
1135
+ };
1136
+ }
1137
+ if (type?.kind !== "string")
1138
+ this.fail(node, "PLC2001", "String literals require a bounded STRING or WSTRING context.");
1139
+ return this.checked({
1140
+ ...origin,
1141
+ type: type.id,
1142
+ kind: "constant",
1143
+ value: { kind: "string", value: node.text },
1144
+ });
1145
+ }
1146
+ if (ts.isObjectLiteralExpression(node)) {
1147
+ const type = expected ? this.resolver.types.get(expected) : undefined;
1148
+ if (type?.kind === "array" && type.index) {
1149
+ // Like an array literal, every element in order, so items evaluate in source order.
1150
+ const order = "Enum-keyed record literals list every enum member once, in value order.";
1151
+ const items: Expression[] = [];
1152
+ for (const property of node.properties) {
1153
+ if (!ts.isPropertyAssignment(property) || !ts.isComputedPropertyName(property.name))
1154
+ this.fail(
1155
+ property,
1156
+ "PLC1001",
1157
+ "Enum-keyed record literals use computed member keys such as [Station.A].",
1158
+ );
1159
+ const key = this.expression(property.name.expression, type.index);
1160
+ if (key.kind !== "constant" || key.type !== type.index || key.value.kind !== "integer")
1161
+ this.fail(property.name, "PLC2001", "Keys must be members of the record's enum.");
1162
+ if (Number(key.value.value) !== type.lowerBound + items.length)
1163
+ this.fail(property, "PLC2001", order);
1164
+ items.push(this.implicitCopy(this.expression(property.initializer, type.element)));
1165
+ }
1166
+ if (items.length !== type.length) this.fail(node, "PLC2001", order);
1167
+ return this.checked({ ...origin, type: type.id, kind: "array", items });
1168
+ }
1169
+ if (type?.kind !== "record")
1170
+ this.fail(node, "PLC2001", "Record literals require an explicit closed PLC record type.");
1171
+ const fields: Record<string, Expression> = Object.create(null);
1172
+ const written = new Set<string>();
1173
+ for (const [position, property] of node.properties.entries()) {
1174
+ if (ts.isSpreadAssignment(property) && !position) {
1175
+ for (const [name, value] of Object.entries(
1176
+ this.spreadFields(property.expression, type.fields),
1177
+ )) {
1178
+ // TypeScript's spread shares a nested record or array, so a later write to it diverges.
1179
+ this.recordCopySource(value);
1180
+ fields[name] = this.implicitCopy(value);
1181
+ }
1182
+ continue;
1183
+ }
1184
+ if (
1185
+ !(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) ||
1186
+ !ts.isIdentifier(property.name)
1187
+ )
1188
+ this.fail(
1189
+ property,
1190
+ "PLC1001",
1191
+ "Record literals take named fields after at most one leading spread; methods are unsupported.",
1192
+ );
1193
+ const fieldName = property.name.text;
1194
+ const field = type.fields.find((field) => field.name === fieldName);
1195
+ if (!field || written.has(field.name))
1196
+ this.fail(property, "PLC2001", "Unknown or duplicate record field.");
1197
+ written.add(field.name);
1198
+ // An explicit field overrides the spread and keeps its source evaluation order.
1199
+ delete fields[field.name];
1200
+ fields[field.name] = this.implicitCopy(
1201
+ this.expression(
1202
+ ts.isPropertyAssignment(property) ? property.initializer : property.name,
1203
+ field.type,
1204
+ ),
1205
+ );
1206
+ }
1207
+ if (this.resolver.unions.has(type.id))
1208
+ for (const field of type.fields)
1209
+ if (!Object.hasOwn(fields, field.name))
1210
+ fields[field.name] = {
1211
+ ...origin,
1212
+ type: field.type,
1213
+ kind: "constant",
1214
+ value: defaultValue(field.type, this.resolver.types),
1215
+ };
1216
+ return this.checked({ ...origin, type: type.id, kind: "record", fields });
1217
+ }
1218
+ if (ts.isArrayLiteralExpression(node)) {
1219
+ const type = expected ? this.resolver.types.get(expected) : undefined;
1220
+ if (type?.kind !== "array")
1221
+ this.fail(node, "PLC2001", "Array literals require an explicit FixedArray type.");
1222
+ const items = node.elements.map((element) => {
1223
+ if (ts.isOmittedExpression(element) || ts.isSpreadElement(element))
1224
+ this.fail(element, "PLC1001", "Fixed arrays cannot have holes or spreading.");
1225
+ return this.implicitCopy(this.expression(element, type.element));
1226
+ });
1227
+ return this.checked({ ...origin, type: type.id, kind: "array", items });
1228
+ }
1229
+ if (ts.isPropertyAccessExpression(node)) {
1230
+ const declaration = this.resolver.symbol(node.name).declarations?.find(ts.isEnumMember);
1231
+ if (declaration) {
1232
+ const type = this.resolver.enumeration(declaration.parent).type;
1233
+ const layout = this.resolver.types.get(type)!;
1234
+ if (layout.kind !== "enum") this.fail(node, "PLC2001", "Invalid enum layout.");
1235
+ return {
1236
+ ...origin,
1237
+ type,
1238
+ kind: "constant",
1239
+ value: {
1240
+ kind: "integer",
1241
+ value: layout.members.find((member) => member.name === declaration.name.getText())!
1242
+ .value,
1243
+ },
1244
+ };
1245
+ }
1246
+ if (node.name.text === "length") {
1247
+ const base = this.expression(node.expression),
1248
+ type = this.resolver.types.get(base.type);
1249
+ if (type?.kind === "array")
1250
+ return { ...origin, type: "DINT", kind: "length", operand: base };
1251
+ }
1252
+ }
1253
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
1254
+ const place = this.place(node),
1255
+ type = placeType(place, this.resolver.types, this.byId(), this.ranges);
1256
+ if (type.issue || !type.type) this.fail(node, "PLC2001", type.issue ?? "Invalid PLC place.");
1257
+ return { ...origin, type: type.type, kind: "load", place };
1258
+ }
1259
+ if (ts.isIdentifier(node) && this.aliases.has(this.resolver.symbol(node))) {
1260
+ const place = this.place(node),
1261
+ type = placeType(place, this.resolver.types, this.byId(), this.ranges);
1262
+ if (type.issue || !type.type) this.fail(node, "PLC2001", type.issue ?? "Invalid PLC place.");
1263
+ return { ...origin, type: type.type, kind: "load", place };
1264
+ }
1265
+ if (ts.isIdentifier(node)) {
1266
+ const variable = this.variables.get(this.resolver.symbol(node));
1267
+ if (!variable) this.fail(node, "PLC1002", "This value has no supported PLC storage.");
1268
+ if (variable.type === "inferred")
1269
+ this.fail(
1270
+ node,
1271
+ "PLC3001",
1272
+ "Resolve constant initializer dependencies in declaration order.",
1273
+ );
1274
+ return {
1275
+ ...origin,
1276
+ type: variable.type,
1277
+ kind: "load",
1278
+ place: { root: variable.id, path: [] },
1279
+ };
1280
+ }
1281
+ if (ts.isPrefixUnaryExpression(node)) {
1282
+ const operator =
1283
+ node.operator === ts.SyntaxKind.ExclamationToken
1284
+ ? "not"
1285
+ : node.operator === ts.SyntaxKind.MinusToken
1286
+ ? "negate"
1287
+ : node.operator === ts.SyntaxKind.TildeToken
1288
+ ? "bitNot"
1289
+ : undefined;
1290
+ if (!operator) this.fail(node, "PLC2001", "Unsupported unary operation.");
1291
+ const operand = this.expression(node.operand, operator === "not" ? "BOOL" : expected);
1292
+ return this.checked({
1293
+ ...origin,
1294
+ type: operator === "not" ? "BOOL" : operand.type,
1295
+ kind: "unary",
1296
+ operator,
1297
+ operand,
1298
+ });
1299
+ }
1300
+ if (ts.isCallExpression(node)) {
1301
+ const sdk = this.sdkExpression(node, expected);
1302
+ if (sdk) return sdk;
1303
+ const call = this.call(node);
1304
+ if (!call.fn.result)
1305
+ this.fail(node, "PLC2001", "A void helper cannot produce an expression value.");
1306
+ return {
1307
+ ...origin,
1308
+ type: call.fn.result,
1309
+ kind: "call",
1310
+ function: call.fn.id,
1311
+ arguments: call.arguments,
1312
+ ...(call.instance && { instance: call.instance }),
1313
+ ...(call.index && { index: call.index }),
1314
+ };
1315
+ }
1316
+ if (ts.isConditionalExpression(node)) {
1317
+ const then = this.expression(node.whenTrue, expected);
1318
+ return {
1319
+ ...origin,
1320
+ type: then.type,
1321
+ kind: "conditional",
1322
+ condition: this.expression(node.condition, "BOOL"),
1323
+ then,
1324
+ else: this.expression(node.whenFalse, then.type),
1325
+ };
1326
+ }
1327
+ if (ts.isBinaryExpression(node)) return this.binaryExpression(node, expected);
1328
+ this.fail(node, "PLC1001", `${ts.SyntaxKind[node.kind]} is not supported in PLC expressions.`);
1329
+ }
1330
+
1331
+ private binaryExpression(node: ts.BinaryExpression, expected?: string): Expression {
1332
+ const origin = { span: span(node) };
1333
+ const operator = operators.get(node.operatorToken.kind);
1334
+ if (!operator)
1335
+ this.fail(
1336
+ node.operatorToken,
1337
+ "PLC2001",
1338
+ "Unsupported binary operation; implicit coercion is prohibited.",
1339
+ );
1340
+ const logical = operator === "and" || operator === "or";
1341
+ const comparison = comparisonOperators.has(operator);
1342
+ const leftIsLiteral = this.contextualLiteral(node.left);
1343
+ const context = logical
1344
+ ? "BOOL"
1345
+ : comparison
1346
+ ? leftIsLiteral
1347
+ ? this.expression(node.right).type
1348
+ : undefined
1349
+ : expected;
1350
+ let left = this.expression(node.left, context);
1351
+ if (comparison && !leftIsLiteral && !this.contextualLiteral(node.right)) {
1352
+ // Compare at the wider type; widening cannot change the value.
1353
+ const wider = this.expression(node.right).type,
1354
+ from = this.resolver.types.get(left.type),
1355
+ to = this.resolver.types.get(wider);
1356
+ if (from && to && !canAssign(to, from) && canAssign(from, to))
1357
+ left = this.coerce(left, wider);
1358
+ }
1359
+ const right = this.expression(
1360
+ node.right,
1361
+ operator === "shiftLeft" || operator === "shiftRight" ? "DINT" : left.type,
1362
+ );
1363
+ if (operator === "divide" && this.resolver.types.get(left.type)?.kind !== "float")
1364
+ this.fail(node, "PLC2002", "Use div<T>() for truncating integer division.");
1365
+ return this.checked({
1366
+ ...origin,
1367
+ type: logical || comparison ? "BOOL" : left.type,
1368
+ kind: "binary",
1369
+ operator,
1370
+ left,
1371
+ right,
1372
+ });
1373
+ }
1374
+
1375
+ private sdkExpression(node: ts.CallExpression, expected?: string): Expression | undefined {
1376
+ const origin = { span: span(node) };
1377
+ const official = this.sdkCall(node);
1378
+ if (official === "errorCode") {
1379
+ if (node.arguments.length !== 1)
1380
+ this.fail(node, "PLC1012", "errorCode requires one rejection reason.");
1381
+ return this.expression(node.arguments[0]!, "UDINT");
1382
+ }
1383
+ if (
1384
+ official &&
1385
+ (Object.hasOwn(durationScales, official) || Object.hasOwn(calendarTypes, official))
1386
+ ) {
1387
+ if (node.arguments.length !== 1 || node.typeArguments?.length)
1388
+ this.fail(node, "PLC2001", "Time constructors require one compile-time literal argument.");
1389
+ const argument = node.arguments[0]!;
1390
+ const scale = durationScales[official];
1391
+ let value: Constant | undefined, type: string;
1392
+ if (scale) {
1393
+ type = "TIME";
1394
+ let text = this.numericLiteral(argument);
1395
+ if (text === undefined) {
1396
+ const folded = this.constant(this.expression(argument));
1397
+ if (folded?.kind === "integer") text = folded.value;
1398
+ }
1399
+ if (text !== undefined && /^-?0[xob]/i.test(text)) text = String(parseInteger(text));
1400
+ value = text === undefined ? undefined : durationLiteral(text.replace(/n$/, ""), scale);
1401
+ } else {
1402
+ type = calendarTypes[official]!;
1403
+ const layout = this.resolver.types.get(type)!;
1404
+ value =
1405
+ layout.kind === "calendar" &&
1406
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
1407
+ ? calendarLiteral(layout.domain, argument.text)
1408
+ : undefined;
1409
+ }
1410
+ if (!value)
1411
+ this.fail(
1412
+ argument,
1413
+ "PLC2002",
1414
+ "Invalid time literal: require an exact supported domain value and millisecond TIME precision.",
1415
+ );
1416
+ return this.checked({ ...origin, type, kind: "constant", value });
1417
+ }
1418
+ if (official === "now") {
1419
+ if (node.arguments.length || node.typeArguments?.length)
1420
+ this.fail(node, "PLC2001", "now() takes no arguments.");
1421
+ return this.checked({
1422
+ ...origin,
1423
+ type: "DINT",
1424
+ kind: "unary",
1425
+ operator: "clock",
1426
+ operand: {
1427
+ ...origin,
1428
+ type: "DINT",
1429
+ kind: "constant",
1430
+ value: { kind: "integer", value: "0" },
1431
+ },
1432
+ });
1433
+ }
1434
+ if (official === "timeToMilliseconds") {
1435
+ if (node.arguments.length !== 1 || node.typeArguments?.length)
1436
+ this.fail(
1437
+ node,
1438
+ "PLC2001",
1439
+ "timeToMilliseconds requires one TIME argument and no type arguments.",
1440
+ );
1441
+ return this.checked({
1442
+ ...origin,
1443
+ type: "UDINT",
1444
+ kind: "unary",
1445
+ operator: "timeToMilliseconds",
1446
+ operand: this.expression(node.arguments[0]!),
1447
+ });
1448
+ }
1449
+ if (official === "defaultValue") {
1450
+ if (node.arguments.length || node.typeArguments?.length !== 1)
1451
+ this.fail(
1452
+ node,
1453
+ "PLC2001",
1454
+ "defaultValue requires exactly one concrete type and no value arguments.",
1455
+ );
1456
+ const type = this.type(node.typeArguments[0]!);
1457
+ if (!this.resolver.isValue(type))
1458
+ this.fail(
1459
+ node,
1460
+ "PLC2001",
1461
+ "defaultValue requires a fixed value layout, not an instance or borrow schema.",
1462
+ );
1463
+ if (type.io) this.fail(node, "PLC4002", "Default values cannot create logical I/O.");
1464
+ return this.checked({
1465
+ ...origin,
1466
+ type: type.type,
1467
+ kind: "constant",
1468
+ value: defaultValue(type.type, this.resolver.types),
1469
+ });
1470
+ }
1471
+ if (official === "copy") {
1472
+ if (node.arguments.length !== 1 || (node.typeArguments && node.typeArguments.length !== 1))
1473
+ this.fail(node, "PLC2001", "copy requires one fixed aggregate value.");
1474
+ const operand = this.expression(
1475
+ node.arguments[0]!,
1476
+ node.typeArguments?.[0] ? this.type(node.typeArguments[0]).type : expected,
1477
+ );
1478
+ return this.checked({ ...origin, type: operand.type, kind: "copy", operand });
1479
+ }
1480
+ if (official && (Object.hasOwn(conversions, official) || Object.hasOwn(arithmetic, official))) {
1481
+ const argument = node.typeArguments?.[0];
1482
+ if (!argument || node.typeArguments?.length !== 1)
1483
+ this.fail(
1484
+ node,
1485
+ "PLC2001",
1486
+ "Numeric SDK operations require one explicit PLC type argument.",
1487
+ );
1488
+ const target = this.type(argument);
1489
+ if (target.io)
1490
+ this.fail(argument, "PLC2001", "I/O wrappers cannot select an operation type.");
1491
+ const conversion = conversions[official];
1492
+ if (conversion) {
1493
+ if (node.arguments.length !== 1)
1494
+ this.fail(node, "PLC2001", "Conversion needs exactly one operand.");
1495
+ return this.checked({
1496
+ ...origin,
1497
+ type: target.type,
1498
+ kind: "convert",
1499
+ policy: conversion,
1500
+ operand: this.expression(node.arguments[0]!),
1501
+ });
1502
+ }
1503
+ const operation = arithmetic[official]!;
1504
+ if (node.arguments.length !== 2)
1505
+ this.fail(node, "PLC2001", "Arithmetic SDK operation needs two operands.");
1506
+ return this.checked({
1507
+ ...origin,
1508
+ type: target.type,
1509
+ kind: "binary",
1510
+ ...operation,
1511
+ left: this.expression(node.arguments[0]!, target.type),
1512
+ right: this.expression(node.arguments[1]!, target.type),
1513
+ });
1514
+ }
1515
+ return undefined;
1516
+ }
1517
+
1518
+ private sdkCall(node: ts.CallExpression): string | undefined {
1519
+ const name = calleeName(node.expression);
1520
+ return name && this.resolver.sdkName(name);
1521
+ }
1522
+
1523
+ private update(node: ts.CallExpression): Statement | undefined {
1524
+ if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "update")
1525
+ return undefined;
1526
+ const place = this.place(node.expression.expression),
1527
+ variable = this.byId().get(place.root),
1528
+ type = variable && instancedBlock(variable.type, this.resolver.types),
1529
+ [selector, ...rest] = place.path,
1530
+ index = selector?.kind === "index" ? selector.value : undefined;
1531
+ if (
1532
+ !type ||
1533
+ rest.length ||
1534
+ (this.resolver.types.get(variable.type)?.kind === "array" ? !index : selector)
1535
+ )
1536
+ this.fail(node, "PLC1007", "Update requires a static block instance binding.");
1537
+ const definition = this.blocks.definitions.get(type.block)!;
1538
+ if (
1539
+ node.typeArguments?.length ||
1540
+ node.arguments.length !== 1 ||
1541
+ !ts.isObjectLiteralExpression(node.arguments[0]!)
1542
+ )
1543
+ this.fail(node, "PLC2001", "Block update requires one complete named input record.");
1544
+ const inputs: Record<string, Expression> = Object.create(null);
1545
+ const written = new Set<string>();
1546
+ for (const [position, property] of node.arguments[0].properties.entries()) {
1547
+ if (ts.isSpreadAssignment(property) && !position) {
1548
+ for (const [name, value] of Object.entries(
1549
+ this.spreadFields(property.expression, definition.inputs),
1550
+ )) {
1551
+ const parameter = definition.inputs.find((input) => input.name === name)!;
1552
+ inputs[name] = parameter.mode === "inOut" ? this.reference(value, parameter) : value;
1553
+ }
1554
+ continue;
1555
+ }
1556
+ if (
1557
+ !(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) ||
1558
+ !ts.isIdentifier(property.name)
1559
+ )
1560
+ this.fail(
1561
+ property,
1562
+ "PLC2001",
1563
+ "Block inputs are named fields after at most one leading spread.",
1564
+ );
1565
+ const name = property.name.text;
1566
+ const parameter = definition.inputs.find((input) => input.name === name);
1567
+ if (!parameter || written.has(parameter.name))
1568
+ this.fail(property, "PLC2001", "Unknown or duplicate block input.");
1569
+ written.add(parameter.name);
1570
+ const value = ts.isPropertyAssignment(property) ? property.initializer : property.name;
1571
+ delete inputs[parameter.name];
1572
+ inputs[parameter.name] = this.inputArgument(value, parameter);
1573
+ }
1574
+ if (definition.inputs.some((input) => !Object.hasOwn(inputs, input.name)))
1575
+ this.fail(node, "PLC2001", "Block update must bind every input on each call.");
1576
+ return {
1577
+ kind: "blockUpdate",
1578
+ instance: place.root,
1579
+ ...(index && { index }),
1580
+ inputs,
1581
+ span: span(node),
1582
+ };
1583
+ }
1584
+
1585
+ private call(node: ts.CallExpression): {
1586
+ fn: PlcFunction;
1587
+ arguments: Expression[];
1588
+ instance?: string;
1589
+ index?: Expression;
1590
+ } {
1591
+ const name = calleeName(node.expression);
1592
+ if (!name) this.fail(node, "PLC1001", "Call a statically resolved PLC helper or block method.");
1593
+ const symbol = this.resolver.symbol(name);
1594
+ // Structural return types can give a receiver a transient property symbol.
1595
+ // Resolve its declaration back to the canonical instance method identity.
1596
+ const method =
1597
+ this.methods.get(symbol) ??
1598
+ symbol.declarations
1599
+ ?.filter(ts.isMethodDeclaration)
1600
+ .map((node) => this.methods.get(this.resolver.symbol(node.name)))
1601
+ .find((fn) => fn !== undefined);
1602
+ if (method) {
1603
+ if (!ts.isPropertyAccessExpression(node.expression) || node.typeArguments?.length)
1604
+ this.fail(node, "PLC1007", "Methods require a static instance receiver.");
1605
+ const receiver = node.expression.expression;
1606
+ let instance: string | undefined;
1607
+ let index: Expression | undefined;
1608
+ if (receiver.kind === ts.SyntaxKind.ThisKeyword) {
1609
+ if (method.owner?.block !== this.activeBlock?.block.id)
1610
+ this.fail(node, "PLC1007", "Method receiver differs from the enclosing block.");
1611
+ } else {
1612
+ const place = this.place(receiver);
1613
+ const variable = this.byId().get(place.root);
1614
+ const type = variable && instancedBlock(variable.type, this.resolver.types);
1615
+ const [selector, ...rest] = place.path;
1616
+ index = selector?.kind === "index" ? selector.value : undefined;
1617
+ if (
1618
+ (variable?.storage !== "instance" && variable?.mode !== "inOut") ||
1619
+ rest.length ||
1620
+ (this.resolver.types.get(variable.type)?.kind === "array" ? !index : selector) ||
1621
+ type?.block !== method.owner?.block
1622
+ )
1623
+ this.fail(node, "PLC1007", "Methods require a matching static block instance.");
1624
+ if (method.owner?.access !== "public" && method.owner?.block !== this.activeBlock?.block.id)
1625
+ this.fail(
1626
+ node,
1627
+ "PLC1007",
1628
+ "Private and protected methods are only accessible within their block.",
1629
+ );
1630
+ instance = place.root;
1631
+ }
1632
+ return {
1633
+ fn: method,
1634
+ arguments: this.callArguments(node, method),
1635
+ ...(instance && { instance }),
1636
+ ...(index && { index }),
1637
+ };
1638
+ }
1639
+ const declaration = this.functionDeclarations.get(symbol);
1640
+ if (!declaration)
1641
+ this.fail(node, "PLC1001", "Calls require PLC TypeScript implementation source.");
1642
+ const fn = declaration.typeParameters?.length
1643
+ ? this.instantiate(declaration, node.typeArguments ?? [], node)
1644
+ : this.functions.get(symbol);
1645
+ if (!fn || (!declaration.typeParameters?.length && node.typeArguments?.length))
1646
+ this.fail(node, "PLC2001", "Type arguments require a generic helper.");
1647
+ return { fn, arguments: this.callArguments(node, fn) };
1648
+ }
1649
+
1650
+ private callArguments(node: ts.CallExpression, fn: PlcFunction): Expression[] {
1651
+ if (node.arguments.length !== fn.parameters.length)
1652
+ this.fail(node, "PLC2001", "PLC argument count differs from its signature.");
1653
+ return fn.parameters.map((parameter, index) =>
1654
+ this.inputArgument(node.arguments[index]!, parameter),
1655
+ );
1656
+ }
1657
+
1658
+ private inputArgument(value: ts.Expression, parameter: Variable): Expression {
1659
+ if (parameter.mode !== "inOut") return this.expression(value, parameter.type);
1660
+ if (ts.isCallExpression(value) && this.sdkCall(value) === "ref") {
1661
+ if (value.arguments.length !== 1 || value.typeArguments?.length)
1662
+ this.fail(value, "PLC3003", "ref takes one place.");
1663
+ const target = this.target(value.arguments[0]!);
1664
+ if (target.type !== parameter.type)
1665
+ this.fail(value, "PLC2001", "Borrowed place type must exactly match its InOut parameter.");
1666
+ return { kind: "borrow", place: target.place, type: target.type, span: span(value) };
1667
+ }
1668
+ if (this.resolver.types.get(parameter.type)?.kind === "block") {
1669
+ const place = this.place(value);
1670
+ if (placeType(place, this.resolver.types, this.byId(), this.ranges).type !== parameter.type)
1671
+ this.fail(value, "PLC2001", "Pass a block instance of the parameter's type.");
1672
+ return { kind: "borrow", place, type: parameter.type, span: span(value) };
1673
+ }
1674
+ return this.reference(this.expression(value, parameter.type), parameter);
1675
+ }
1676
+
1677
+ /**
1678
+ * An aggregate InOut argument borrows its place; a value with no writable address reaches
1679
+ * the callee through a compiler temporary.
1680
+ */
1681
+ private reference(argument: Expression, parameter: Variable): Expression {
1682
+ if (!aggregate(this.resolver.types.get(parameter.type)))
1683
+ throw new SemanticError(
1684
+ error(
1685
+ "PLC3003",
1686
+ "Scalar InOut arguments require a direct ref(place) borrow.",
1687
+ argument.span,
1688
+ ),
1689
+ );
1690
+ return argument.kind === "load" &&
1691
+ !this.loopCounters.has(argument.place.root) &&
1692
+ !placeType(argument.place, this.resolver.types, this.byId(), this.ranges, true).issue
1693
+ ? { kind: "borrow", place: argument.place, type: argument.type, span: argument.span }
1694
+ : this.implicitCopy(argument);
1695
+ }
1696
+
1697
+ /**
1698
+ * The fields `{ ...base }` supplies: base is a record whose fields are all target fields of the
1699
+ * same type. A stored base is read field by field; `defaultValue<T>()` gives constants.
1700
+ */
1701
+ private spreadFields(
1702
+ node: ts.Expression,
1703
+ targets: readonly { name: string; type: string }[],
1704
+ ): Record<string, Expression> {
1705
+ let base = this.expression(node);
1706
+ const layout = this.resolver.types.get(base.type);
1707
+ if (
1708
+ layout?.kind !== "record" ||
1709
+ layout.fields.some(
1710
+ (field) => targets.find((target) => target.name === field.name)?.type !== field.type,
1711
+ )
1712
+ )
1713
+ this.fail(
1714
+ node,
1715
+ "PLC2001",
1716
+ "A spread is a record whose fields are all fields of the target, with the same types.",
1717
+ );
1718
+ // Static initializers run before any scan, so a stored base contributes its initial value.
1719
+ const initial =
1720
+ !this.activeFunction && base.kind === "load" && !base.place.path.length
1721
+ ? this.byId().get(base.place.root)?.initial
1722
+ : undefined;
1723
+ if (initial) base = { ...base, kind: "constant", value: initial };
1724
+ if (base.kind === "load") {
1725
+ const { place } = base;
1726
+ return Object.fromEntries(
1727
+ layout.fields.map((field) => [
1728
+ field.name,
1729
+ {
1730
+ kind: "load",
1731
+ type: field.type,
1732
+ place: { root: place.root, path: [...place.path, { kind: "field", name: field.name }] },
1733
+ span: base.span,
1734
+ },
1735
+ ]),
1736
+ );
1737
+ }
1738
+ if (base.kind !== "constant" || base.value.kind !== "record")
1739
+ this.fail(
1740
+ node,
1741
+ "PLC1001",
1742
+ "Spread a record in storage or defaultValue<T>(); bind other values to a const first.",
1743
+ );
1744
+ const { value } = base;
1745
+ return Object.fromEntries(
1746
+ layout.fields.map((field) => [
1747
+ field.name,
1748
+ { kind: "constant", type: field.type, value: value.fields[field.name]!, span: base.span },
1749
+ ]),
1750
+ );
1751
+ }
1752
+
1753
+ private statements(nodes: readonly ts.Statement[], locals: Variable[]): Statement[] {
1754
+ return nodes.flatMap((node) => this.statement(node, locals));
1755
+ }
1756
+
1757
+ private body(node: ts.Statement, locals: Variable[]): Statement[] {
1758
+ return ts.isBlock(node)
1759
+ ? this.statements(node.statements, locals)
1760
+ : this.statement(node, locals);
1761
+ }
1762
+
1763
+ /** A record or array assigned into storage copies: fixed storage cannot repoint at its source. */
1764
+ private implicitCopy(value: Expression): Expression {
1765
+ if (instancedBlock(value.type, this.resolver.types))
1766
+ throw new SemanticError(
1767
+ error("PLC3003", "Block instances cannot be copied, aliased or returned.", value.span),
1768
+ );
1769
+ return aggregate(this.resolver.types.get(value.type)) && value.kind === "load"
1770
+ ? { kind: "copy", type: value.type, operand: value, span: value.span }
1771
+ : value;
1772
+ }
1773
+
1774
+ /** Remember a copied source, so a later write to it in this routine cannot silently diverge. */
1775
+ private recordCopySource(value: Expression): void {
1776
+ if (value.kind === "load" && aggregate(this.resolver.types.get(value.type)))
1777
+ this.copySources.push(value.place);
1778
+ }
1779
+
1780
+ /** An alias re-reads its path at each use, so its indexes must keep their value for its scope. */
1781
+ private unchanging(value: Expression): boolean {
1782
+ if (value.kind === "constant") return true;
1783
+ if (value.kind === "convert") return this.unchanging(value.operand);
1784
+ if (value.kind === "binary") return this.unchanging(value.left) && this.unchanging(value.right);
1785
+ if (value.kind !== "load" || value.place.path.length) return false;
1786
+ const root = this.byId().get(value.place.root);
1787
+ return (
1788
+ this.loopCounters.has(value.place.root) ||
1789
+ Boolean(root && !root.mutable && ["parameter", "local", "constant"].includes(root.storage))
1790
+ );
1791
+ }
1792
+
1793
+ private place(node: ts.Expression): Place {
1794
+ if (ts.isParenthesizedExpression(node)) return this.place(node.expression);
1795
+ if (this.activeBlock && ts.isPropertyAccessExpression(node)) {
1796
+ if (
1797
+ ts.isIdentifier(node.expression) &&
1798
+ this.resolver.symbol(node.expression) === this.activeBlock.inputSymbol
1799
+ ) {
1800
+ const variable = this.activeBlock.inputBindings.get(node.name.text);
1801
+ if (!variable) this.fail(node, "PLC2001", "Unknown block input field.");
1802
+ return { root: variable.id, path: [] };
1803
+ }
1804
+ if (
1805
+ ts.isPropertyAccessExpression(node.expression) &&
1806
+ node.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
1807
+ node.expression.name.text === "out"
1808
+ ) {
1809
+ const variable = this.activeBlock.block.outputs.find(
1810
+ (output) => output.name === node.name.text,
1811
+ );
1812
+ if (!variable) this.fail(node, "PLC2001", "Unknown block output field.");
1813
+ return { root: variable.id, path: [] };
1814
+ }
1815
+ }
1816
+ if (
1817
+ ts.isPropertyAccessExpression(node) &&
1818
+ this.activeBlock?.fieldBindings.has(this.resolver.symbol(node.name)) &&
1819
+ node.expression.kind !== ts.SyntaxKind.ThisKeyword
1820
+ )
1821
+ this.fail(node, "PLC1007", "Private block state is accessible through this.field only.");
1822
+ if (ts.isIdentifier(node)) {
1823
+ const alias = this.aliases.get(this.resolver.symbol(node));
1824
+ if (alias) return alias;
1825
+ }
1826
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
1827
+ const variable = this.variables.get(
1828
+ this.resolver.symbol(ts.isIdentifier(node) ? node : node.name),
1829
+ );
1830
+ if (variable) return { root: variable.id, path: [] };
1831
+ }
1832
+ if (ts.isPropertyAccessExpression(node)) {
1833
+ const base = this.place(node.expression);
1834
+ return { root: base.root, path: [...base.path, { kind: "field", name: node.name.text }] };
1835
+ }
1836
+ if (ts.isElementAccessExpression(node) && node.argumentExpression) {
1837
+ const base = this.place(node.expression),
1838
+ value = this.expression(node.argumentExpression),
1839
+ array = this.resolver.types.get(
1840
+ placeType(base, this.resolver.types, this.byId(), this.ranges).type ?? "",
1841
+ );
1842
+ if (array?.kind === "array" && array.index && value.type !== array.index)
1843
+ this.fail(node, "PLC2001", "An enum-keyed record is indexed only by its own enum.");
1844
+ return { root: base.root, path: [...base.path, { kind: "index", value }] };
1845
+ }
1846
+ this.fail(node, "PLC1001", "Access requires statically allocated PLC storage.");
1847
+ }
1848
+
1849
+ private target(node: ts.Expression): { place: Place; type: string } {
1850
+ if (ts.isIdentifier(node)) {
1851
+ const binding = this.checker.getSymbolAtLocation(node);
1852
+ if (binding && binding.flags & ts.SymbolFlags.Alias)
1853
+ this.fail(node, "PLC3002", "Imported scalar bindings are read-only.");
1854
+ }
1855
+ const place = this.place(node),
1856
+ type = placeType(place, this.resolver.types, this.byId(), this.ranges, true);
1857
+ if (type.issue || !type.type || this.loopCounters.has(place.root))
1858
+ this.fail(node, "PLC3002", type.issue ?? "This PLC storage is read-only.");
1859
+ const overlaps = (source: Place) =>
1860
+ source.root === place.root &&
1861
+ source.path.every((segment, index) => {
1862
+ const other = place.path[index];
1863
+ return (
1864
+ !other ||
1865
+ segment.kind !== "field" ||
1866
+ other.kind !== "field" ||
1867
+ segment.name === other.name
1868
+ );
1869
+ });
1870
+ if (this.copySources.some(overlaps))
1871
+ this.fail(
1872
+ node,
1873
+ "PLC3003",
1874
+ "This value was assigned into other storage, which holds a copy, so this write will not reach it. Assign it afterwards, or use copy(value) if separate values are intended.",
1875
+ );
1876
+ return { place, type: type.type };
1877
+ }
1878
+
1879
+ private statement(node: ts.Statement, locals: Variable[]): Statement[] {
1880
+ const origin = { span: span(node) };
1881
+ if (ts.isBlock(node)) return this.statements(node.statements, locals);
1882
+ if (ts.isEmptyStatement(node)) return [];
1883
+ if (ts.isIfStatement(node)) {
1884
+ const condition = this.expression(node.expression, "BOOL");
1885
+ const original = new Map(this.ranges);
1886
+ const branch = (body: ts.Statement | undefined, truth: boolean) => {
1887
+ this.ranges.clear();
1888
+ for (const [root, range] of narrowRanges(
1889
+ condition,
1890
+ truth,
1891
+ this.resolver.types,
1892
+ this.byId(),
1893
+ original,
1894
+ ))
1895
+ this.ranges.set(root, range);
1896
+ try {
1897
+ return body ? this.body(body, locals) : [];
1898
+ } finally {
1899
+ this.ranges.clear();
1900
+ for (const [root, range] of original) this.ranges.set(root, range);
1901
+ }
1902
+ };
1903
+ return [
1904
+ {
1905
+ ...origin,
1906
+ kind: "if",
1907
+ condition,
1908
+ then: branch(node.thenStatement, true),
1909
+ else: branch(node.elseStatement, false),
1910
+ },
1911
+ ];
1912
+ }
1913
+ if (ts.isReturnStatement(node)) {
1914
+ const result = this.activeFunction?.result;
1915
+ if (!result && node.expression)
1916
+ this.fail(node, "PLC2001", "Void helpers and program entries cannot return a value.");
1917
+ if (result && !node.expression)
1918
+ this.fail(node, "PLC2001", "Typed helpers must return their declared PLC result.");
1919
+ const value =
1920
+ node.expression && result
1921
+ ? this.implicitCopy(this.expression(node.expression, result))
1922
+ : undefined;
1923
+ return [{ ...origin, kind: "return", ...(value && { value }) }];
1924
+ }
1925
+ if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) {
1926
+ if (ts.isBreakStatement(node) && this.breakTargets.at(-1) === "switch")
1927
+ this.fail(
1928
+ node,
1929
+ "PLC1001",
1930
+ "Switch break must terminate its case; nested conditional switch breaks are unsupported.",
1931
+ );
1932
+ if (node.label || !this.loopCounters.size)
1933
+ this.fail(
1934
+ node,
1935
+ "PLC1001",
1936
+ "Loop control requires an enclosing bounded loop and cannot use a label.",
1937
+ );
1938
+ return [{ ...origin, kind: ts.isBreakStatement(node) ? "break" : "continue" }];
1939
+ }
1940
+ if (ts.isVariableStatement(node)) return this.localDeclarations(node, locals);
1941
+ if (ts.isForStatement(node)) return this.loop(node, locals);
1942
+ if (ts.isForOfStatement(node)) return this.forOf(node, locals);
1943
+ if (ts.isSwitchStatement(node)) return this.switchStatement(node, locals);
1944
+ if (ts.isExpressionStatement(node)) {
1945
+ const expression = node.expression;
1946
+ if (ts.isCallExpression(expression)) {
1947
+ const update = this.update(expression);
1948
+ if (update) return [update];
1949
+ const call = this.call(expression);
1950
+ return [
1951
+ {
1952
+ ...origin,
1953
+ kind: "invoke",
1954
+ function: call.fn.id,
1955
+ arguments: call.arguments,
1956
+ ...(call.instance && { instance: call.instance }),
1957
+ ...(call.index && { index: call.index }),
1958
+ },
1959
+ ];
1960
+ }
1961
+ if (ts.isBinaryExpression(expression)) {
1962
+ const variable = this.target(expression.left);
1963
+ if (expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
1964
+ this.fail(expression, "PLC2001", "Use explicit assignment and arithmetic policies.");
1965
+ const value = this.expression(expression.right, variable.type);
1966
+ this.recordCopySource(value);
1967
+ return [
1968
+ {
1969
+ ...origin,
1970
+ kind: "assign",
1971
+ destination: variable.place,
1972
+ value: this.implicitCopy(value),
1973
+ },
1974
+ ];
1975
+ }
1976
+ }
1977
+ this.fail(node, "PLC1001", `${ts.SyntaxKind[node.kind]} is not supported in PLC scan code.`);
1978
+ }
1979
+
1980
+ private switchStatement(node: ts.SwitchStatement, locals: Variable[]): Statement[] {
1981
+ const origin = { span: span(node) };
1982
+ this.breakTargets.push("switch");
1983
+ try {
1984
+ const value = this.expression(node.expression);
1985
+ const cases: { values: Constant[]; body: Statement[] }[] = [];
1986
+ let fallback: Statement[] = [];
1987
+ let pending: Constant[] = [],
1988
+ pendingDefault = false;
1989
+ for (const clause of node.caseBlock.clauses) {
1990
+ if (ts.isDefaultClause(clause)) pendingDefault = true;
1991
+ else {
1992
+ const constant = this.constant(this.expression(clause.expression, value.type));
1993
+ if (!constant)
1994
+ this.fail(clause, "PLC2001", "Case values must be compile-time constants.");
1995
+ pending.push(constant);
1996
+ }
1997
+ if (!clause.statements.length) continue;
1998
+ let statements = [...clause.statements];
1999
+ const last = statements.at(-1);
2000
+ if (last && ts.isBreakStatement(last) && !last.label) statements = statements.slice(0, -1);
2001
+ else if (statements.length && !(last && ts.isReturnStatement(last)))
2002
+ this.fail(
2003
+ clause,
2004
+ "PLC1001",
2005
+ "Switch cases must end with break or return; fallthrough is prohibited.",
2006
+ );
2007
+ const body = this.statements(statements, locals);
2008
+ if (pending.length) cases.push({ values: pending, body });
2009
+ if (pendingDefault) fallback = body;
2010
+ pending = [];
2011
+ pendingDefault = false;
2012
+ }
2013
+ if (pending.length) cases.push({ values: pending, body: [] });
2014
+ return [{ ...origin, kind: "switch", value, cases, fallback }];
2015
+ } finally {
2016
+ this.breakTargets.pop();
2017
+ }
2018
+ }
2019
+
2020
+ private localDeclarations(node: ts.VariableStatement, locals: Variable[]): Statement[] {
2021
+ const origin = { span: span(node) };
2022
+ if (!(node.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)))
2023
+ this.fail(node, "PLC1001", "Use let or const for invocation locals.");
2024
+ return node.declarationList.declarations.flatMap((declaration) => {
2025
+ if (!ts.isIdentifier(declaration.name))
2026
+ this.fail(declaration, "PLC1001", "Destructuring is not supported.");
2027
+ if (!declaration.initializer)
2028
+ this.fail(declaration, "PLC3001", "Initialize invocation-local PLC variables explicitly.");
2029
+ const annotation = declaration.type ? this.type(declaration.type) : undefined;
2030
+ if (annotation && !this.resolver.isValue(annotation))
2031
+ this.fail(
2032
+ declaration,
2033
+ "PLC2001",
2034
+ "Invocation locals require value types, not instances or borrow schemas.",
2035
+ );
2036
+ if (annotation?.io)
2037
+ this.fail(declaration, "PLC4002", "Logical I/O cannot be invocation-local.");
2038
+ if (
2039
+ !annotation &&
2040
+ !(node.declarationList.flags & ts.NodeFlags.Const) &&
2041
+ this.numericLiteral(declaration.initializer) === undefined &&
2042
+ ![ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(
2043
+ declaration.initializer.kind,
2044
+ )
2045
+ )
2046
+ this.fail(declaration, "PLC2001", "Annotate mutable numeric storage explicitly.");
2047
+ const initial = this.expression(declaration.initializer, annotation?.type);
2048
+ if (
2049
+ node.declarationList.flags & ts.NodeFlags.Const &&
2050
+ initial.kind === "load" &&
2051
+ aggregate(this.resolver.types.get(initial.type))
2052
+ ) {
2053
+ for (const segment of initial.place.path)
2054
+ if (segment.kind === "index" && !this.unchanging(segment.value))
2055
+ this.fail(
2056
+ declaration,
2057
+ "PLC3003",
2058
+ "An alias index must keep its value while the alias is in scope; use copy(value) for a snapshot.",
2059
+ );
2060
+ this.aliases.set(this.resolver.symbol(declaration.name), initial.place);
2061
+ return [];
2062
+ }
2063
+ this.recordCopySource(initial);
2064
+ const value = this.implicitCopy(initial);
2065
+ const variable: Variable = {
2066
+ id: this.storageId(declaration, declaration.name.text),
2067
+ name: declaration.name.text,
2068
+ type: value.type,
2069
+ storage: "local",
2070
+ mutable: !(node.declarationList.flags & ts.NodeFlags.Const),
2071
+ retain: false,
2072
+ span: span(declaration),
2073
+ };
2074
+ this.variables.set(this.resolver.symbol(declaration.name), variable);
2075
+ locals.push(variable);
2076
+ // A const is assigned once, so its initializer's range holds wherever it is read.
2077
+ const bound =
2078
+ !variable.mutable &&
2079
+ valueRange(value, this.resolver.types, this.byId(), this.ranges, this.instances);
2080
+ if (bound) this.ranges.set(variable.id, bound);
2081
+ return { ...origin, kind: "assign", destination: { root: variable.id, path: [] }, value };
2082
+ });
2083
+ }
2084
+
2085
+ private loop(node: ts.ForStatement, locals: Variable[]): Statement[] {
2086
+ let counter: Variable;
2087
+ let initializer: ts.Expression;
2088
+ let existing = false;
2089
+ if (node.initializer && ts.isVariableDeclarationList(node.initializer)) {
2090
+ if (
2091
+ !(node.initializer.flags & ts.NodeFlags.Let) ||
2092
+ node.initializer.declarations.length !== 1
2093
+ )
2094
+ this.fail(
2095
+ node,
2096
+ "PLC1001",
2097
+ "A bounded for loop requires one let counter or an existing counter assignment.",
2098
+ );
2099
+ const declaration = node.initializer.declarations[0]!;
2100
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer)
2101
+ this.fail(node, "PLC2001", "Initialize the integer loop counter.");
2102
+ const annotation = declaration.type ? this.type(declaration.type) : { type: "DINT" };
2103
+ if (annotation.io)
2104
+ this.fail(declaration, "PLC4002", "Loop counters cannot use I/O wrappers.");
2105
+ counter = {
2106
+ id: this.storageId(declaration, declaration.name.text),
2107
+ name: declaration.name.text,
2108
+ type: annotation.type,
2109
+ storage: "local",
2110
+ mutable: true,
2111
+ retain: false,
2112
+ span: span(declaration),
2113
+ };
2114
+ this.variables.set(this.resolver.symbol(declaration.name), counter);
2115
+ locals.push(counter);
2116
+ initializer = declaration.initializer;
2117
+ } else if (
2118
+ node.initializer &&
2119
+ ts.isBinaryExpression(node.initializer) &&
2120
+ node.initializer.operatorToken.kind === ts.SyntaxKind.EqualsToken
2121
+ ) {
2122
+ const target = this.target(node.initializer.left);
2123
+ counter = this.byId().get(target.place.root)!;
2124
+ if (target.place.path.length || !["local", "module", "field"].includes(counter.storage))
2125
+ this.fail(
2126
+ node.initializer,
2127
+ "PLC1001",
2128
+ "An existing loop counter must be a local, module variable or private block field.",
2129
+ );
2130
+ initializer = node.initializer.right;
2131
+ existing = true;
2132
+ } else
2133
+ this.fail(
2134
+ node,
2135
+ "PLC1001",
2136
+ "Initialize one let counter or assign an existing counter in the for header.",
2137
+ );
2138
+ const type = counter.type,
2139
+ plcType = this.resolver.types.get(type);
2140
+ if (plcType?.kind !== "integer")
2141
+ this.fail(node.initializer!, "PLC2001", "Loop counters require integer types.");
2142
+ const startExpression = this.expression(initializer, type);
2143
+ const start = this.constant(startExpression);
2144
+ const isCounter = (expression: ts.Expression): boolean => {
2145
+ const place = this.place(expression);
2146
+ return place.root === counter.id && place.path.length === 0;
2147
+ };
2148
+ const condition = node.condition;
2149
+ if (!condition || !ts.isBinaryExpression(condition) || !isCounter(condition.left))
2150
+ this.fail(node, "PLC1001", "Loop condition must compare its counter to a bounded integer.");
2151
+ const boundExpression = this.expression(condition.right, type);
2152
+ const bound = this.constant(boundExpression);
2153
+ let step: bigint;
2154
+ const increment = node.incrementor;
2155
+ if (
2156
+ increment &&
2157
+ (ts.isPostfixUnaryExpression(increment) || ts.isPrefixUnaryExpression(increment)) &&
2158
+ isCounter(increment.operand) &&
2159
+ [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(increment.operator)
2160
+ )
2161
+ step = increment.operator === ts.SyntaxKind.PlusPlusToken ? 1n : -1n;
2162
+ else if (
2163
+ increment &&
2164
+ ts.isBinaryExpression(increment) &&
2165
+ isCounter(increment.left) &&
2166
+ [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken].includes(
2167
+ increment.operatorToken.kind,
2168
+ )
2169
+ ) {
2170
+ const amount = this.constant(this.expression(increment.right, type));
2171
+ if (amount?.kind !== "integer" || BigInt(amount.value) <= 0n)
2172
+ this.fail(increment, "PLC2001", "Loop step must be a positive constant integer.");
2173
+ step =
2174
+ BigInt(amount.value) *
2175
+ (increment.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken ? 1n : -1n);
2176
+ } else this.fail(node, "PLC1001", "Use i++, i--, i += step or i -= step in the for header.");
2177
+ const operator = condition.operatorToken.kind,
2178
+ ascending = step > 0n;
2179
+ if (
2180
+ !(
2181
+ ascending
2182
+ ? [ts.SyntaxKind.LessThanToken, ts.SyntaxKind.LessThanEqualsToken]
2183
+ : [ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.GreaterThanEqualsToken]
2184
+ ).includes(operator)
2185
+ )
2186
+ this.fail(condition, "PLC2001", "Loop direction and comparison must agree.");
2187
+ const exclusive =
2188
+ operator === ts.SyntaxKind.LessThanToken || operator === ts.SyntaxKind.GreaterThanToken;
2189
+ if (start?.kind !== "integer" || bound?.kind !== "integer") {
2190
+ const startRange = interval(startExpression, this.resolver.types, this.byId(), this.ranges);
2191
+ const limitRange = interval(boundExpression, this.resolver.types, this.byId(), this.ranges);
2192
+ if (!startRange || !limitRange)
2193
+ this.fail(node, "PLC2001", "Loop bounds require finite integer ranges.");
2194
+ const proof = loopBounds(startRange, limitRange, step, exclusive);
2195
+ if (proof.count > 1_000_000n)
2196
+ this.fail(node, "PLC2001", "A scan loop cannot exceed 1,000,000 iterations.");
2197
+ if (existing && proof.count > 0n) {
2198
+ const terminal = (step > 0n ? proof.range[1] : proof.range[0]) + step;
2199
+ const [minimum, maximum] = integerRange(plcType);
2200
+ if (terminal < minimum || terminal > maximum)
2201
+ this.fail(
2202
+ node,
2203
+ "PLC2001",
2204
+ "The existing loop counter would overflow on its final increment.",
2205
+ );
2206
+ }
2207
+ this.loopCounters.add(counter.id);
2208
+ this.ranges.set(counter.id, proof.range);
2209
+ this.breakTargets.push("loop");
2210
+ try {
2211
+ return [
2212
+ {
2213
+ span: span(node),
2214
+ kind: "for",
2215
+ counter: counter.id,
2216
+ ...(existing && { counterScope: "existing" as const }),
2217
+ start: startExpression,
2218
+ limit: boundExpression,
2219
+ step: String(step),
2220
+ ...(exclusive && { exclusive: true as const }),
2221
+ maxIterations: Number(proof.count),
2222
+ body: this.body(node.statement, locals),
2223
+ },
2224
+ ];
2225
+ } finally {
2226
+ this.breakTargets.pop();
2227
+ this.loopCounters.delete(counter.id);
2228
+ this.ranges.delete(counter.id);
2229
+ }
2230
+ }
2231
+ const finish = BigInt(bound.value) + (exclusive ? (ascending ? -1n : 1n) : 0n),
2232
+ first = BigInt(start.value);
2233
+ const count = loopIterations(first, finish, step);
2234
+ if (count > 1_000_000n)
2235
+ this.fail(node, "PLC2001", "A scan loop cannot exceed 1,000,000 iterations.");
2236
+ if (existing) {
2237
+ const terminal = first + count * step,
2238
+ [minimum, maximum] = integerRange(plcType);
2239
+ if (terminal < minimum || terminal > maximum)
2240
+ this.fail(
2241
+ node,
2242
+ "PLC2001",
2243
+ "The existing loop counter would overflow on its final increment.",
2244
+ );
2245
+ }
2246
+ this.loopCounters.add(counter.id);
2247
+ this.ranges.set(counter.id, first <= finish ? [first, finish] : [finish, first]);
2248
+ let body: Statement[];
2249
+ this.breakTargets.push("loop");
2250
+ try {
2251
+ body = this.body(node.statement, locals);
2252
+ } finally {
2253
+ this.breakTargets.pop();
2254
+ this.loopCounters.delete(counter.id);
2255
+ this.ranges.delete(counter.id);
2256
+ }
2257
+ if (!count)
2258
+ return existing
2259
+ ? [
2260
+ {
2261
+ kind: "assign",
2262
+ destination: { root: counter.id, path: [] },
2263
+ value: { kind: "constant", type, value: start, span: span(initializer) },
2264
+ span: span(node.initializer!),
2265
+ },
2266
+ ]
2267
+ : [];
2268
+ const limit: Expression = {
2269
+ kind: "constant",
2270
+ type,
2271
+ value: { kind: "integer", value: String(finish) },
2272
+ span: span(condition.right),
2273
+ };
2274
+ return [
2275
+ {
2276
+ span: span(node),
2277
+ kind: "for",
2278
+ counter: counter.id,
2279
+ ...(existing && { counterScope: "existing" as const }),
2280
+ start: { kind: "constant", type, value: start, span: span(initializer) },
2281
+ limit,
2282
+ step: String(step),
2283
+ maxIterations: Number(count),
2284
+ body,
2285
+ },
2286
+ ];
2287
+ }
2288
+
2289
+ /**
2290
+ * `for (const element of array)` is a bounded FOR over the array's declared bounds whose
2291
+ * element binding aliases `array[counter]`. `array.entries()` also binds the counter. An
2292
+ * enum-keyed Record iterates with `Object.values(record)`, or SDK `entries(record)` for its key.
2293
+ */
2294
+ private forOf(node: ts.ForOfStatement, locals: Variable[]): Statement[] {
2295
+ const list = node.initializer;
2296
+ if (
2297
+ node.awaitModifier ||
2298
+ !ts.isVariableDeclarationList(list) ||
2299
+ !(list.flags & ts.NodeFlags.Const) ||
2300
+ list.declarations.length !== 1
2301
+ )
2302
+ this.fail(node, "PLC1001", "for…of binds one const element of a PLC array.");
2303
+ let iterated = node.expression,
2304
+ form: "elements" | "entries" | "recordValues" | "recordEntries" = "elements";
2305
+ if (ts.isCallExpression(iterated)) {
2306
+ const callee = iterated.expression;
2307
+ if (
2308
+ ts.isPropertyAccessExpression(callee) &&
2309
+ callee.name.text === "entries" &&
2310
+ !iterated.arguments.length
2311
+ ) {
2312
+ form = "entries";
2313
+ iterated = callee.expression;
2314
+ } else if (iterated.arguments.length === 1) {
2315
+ if (this.sdkCall(iterated) === "entries") form = "recordEntries";
2316
+ else if (
2317
+ ts.isPropertyAccessExpression(callee) &&
2318
+ callee.name.text === "values" &&
2319
+ ts.isIdentifier(callee.expression) &&
2320
+ this.resolver.standardLibrary(this.resolver.symbol(callee.expression))
2321
+ )
2322
+ form = "recordValues";
2323
+ if (form !== "elements") iterated = iterated.arguments[0]!;
2324
+ }
2325
+ }
2326
+ const binding = list.declarations[0]!.name,
2327
+ keyed = form === "entries" || form === "recordEntries";
2328
+ const names = ts.isArrayBindingPattern(binding)
2329
+ ? binding.elements.map((element) =>
2330
+ ts.isBindingElement(element) &&
2331
+ !element.dotDotDotToken &&
2332
+ !element.initializer &&
2333
+ ts.isIdentifier(element.name)
2334
+ ? element.name
2335
+ : undefined,
2336
+ )
2337
+ : [undefined, ts.isIdentifier(binding) ? binding : undefined];
2338
+ const [indexName, elementName] = names;
2339
+ if (names.length !== 2 || !elementName || Boolean(indexName) !== keyed)
2340
+ this.fail(
2341
+ binding,
2342
+ "PLC1001",
2343
+ "Bind one element, or destructure [index, element] from array.entries() or entries(record).",
2344
+ );
2345
+ const place = this.place(iterated),
2346
+ array = this.resolver.types.get(
2347
+ placeType(place, this.resolver.types, this.byId(), this.ranges).type ?? "",
2348
+ );
2349
+ if (array?.kind !== "array" || Boolean(array.index) !== form.startsWith("record"))
2350
+ this.fail(
2351
+ node.expression,
2352
+ "PLC2001",
2353
+ "for…of iterates a FixedArray, array.entries(), or an enum-keyed Record through Object.values(record) or entries(record).",
2354
+ );
2355
+ for (const segment of place.path)
2356
+ if (segment.kind === "index" && !this.unchanging(segment.value))
2357
+ this.fail(node.expression, "PLC3003", "An iterated array's index must keep its value.");
2358
+
2359
+ const origin = { span: span(node) };
2360
+ const local = (declaration: ts.Node, name: string, type: string): Variable => {
2361
+ const variable: Variable = {
2362
+ id: this.storageId(declaration, name),
2363
+ name,
2364
+ type,
2365
+ storage: "local",
2366
+ mutable: true,
2367
+ retain: false,
2368
+ span: span(declaration),
2369
+ };
2370
+ locals.push(variable);
2371
+ return variable;
2372
+ };
2373
+ const constant = (type: string, value: string): Expression => ({
2374
+ ...origin,
2375
+ kind: "constant",
2376
+ type,
2377
+ value: { kind: "integer", value },
2378
+ });
2379
+ const counter = local(node, form === "entries" && indexName ? indexName.text : "index", "DINT"),
2380
+ index: Expression = {
2381
+ ...origin,
2382
+ kind: "load",
2383
+ type: "DINT",
2384
+ place: { root: counter.id, path: [] },
2385
+ };
2386
+ const before: Statement[] = [],
2387
+ prefix: Statement[] = [];
2388
+ const readonly = [counter.id];
2389
+ const enumeration = array.index ? this.resolver.types.get(array.index) : undefined;
2390
+ if (indexName && enumeration?.kind === "enum") {
2391
+ // The enum key follows the counter: a CASE at the top of each iteration selects its member.
2392
+ const key = local(indexName, indexName.text, enumeration.id);
2393
+ const assign = (value: string): Statement => ({
2394
+ ...origin,
2395
+ kind: "assign",
2396
+ destination: { root: key.id, path: [] },
2397
+ value: constant(key.type, value),
2398
+ });
2399
+ before.push(assign(String(array.lowerBound)));
2400
+ prefix.push({
2401
+ ...origin,
2402
+ kind: "switch",
2403
+ value: index,
2404
+ cases: enumeration.members.map((member) => ({
2405
+ values: [{ kind: "integer", value: member.value }],
2406
+ body: [assign(member.value)],
2407
+ })),
2408
+ fallback: [],
2409
+ });
2410
+ readonly.push(key.id);
2411
+ this.variables.set(this.resolver.symbol(indexName), key);
2412
+ } else if (indexName) this.variables.set(this.resolver.symbol(indexName), counter);
2413
+ this.aliases.set(this.resolver.symbol(elementName), {
2414
+ root: place.root,
2415
+ path: [...place.path, { kind: "index", value: index }],
2416
+ });
2417
+
2418
+ const upper = array.lowerBound + array.length - 1;
2419
+ for (const id of readonly) this.loopCounters.add(id);
2420
+ this.ranges.set(counter.id, [BigInt(array.lowerBound), BigInt(upper)]);
2421
+ this.breakTargets.push("loop");
2422
+ try {
2423
+ return [
2424
+ ...before,
2425
+ {
2426
+ ...origin,
2427
+ kind: "for",
2428
+ counter: counter.id,
2429
+ start: constant("DINT", String(array.lowerBound)),
2430
+ limit: constant("DINT", String(upper)),
2431
+ step: "1",
2432
+ maxIterations: array.length,
2433
+ body: [...prefix, ...this.body(node.statement, locals)],
2434
+ },
2435
+ ];
2436
+ } finally {
2437
+ this.breakTargets.pop();
2438
+ for (const id of readonly) this.loopCounters.delete(id);
2439
+ this.ranges.delete(counter.id);
2440
+ }
2441
+ }
2442
+ }