@tsplc/build 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1652 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1552 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +17 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/entrypoint.sh +10 -0
  17. package/docker/install/NOTICE.md +21 -0
  18. package/docker/install/core.sh +36 -0
  19. package/docker/install/decode.c +12 -0
  20. package/docker/install/device/Runtime.ico +0 -0
  21. package/docker/install/device/Runtime.png +0 -0
  22. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  23. package/docker/install/extract.py +43 -0
  24. package/docker/install/helper-launcher.c +46 -0
  25. package/docker/install/install.sh +94 -0
  26. package/docker/install/prerequisites.sh +15 -0
  27. package/docker/install/verify.py +133 -0
  28. package/docker/install/verify.sh +34 -0
  29. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  30. package/docker/install/wine-ncrypt/README.md +28 -0
  31. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  32. package/docker/install/wine-ncrypt/main.c +619 -0
  33. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  34. package/docker/install/wine-signal/README.md +22 -0
  35. package/docker/install/wine-signal/build.sh +10 -0
  36. package/docker/install/wine-signal/check.sh +34 -0
  37. package/docker/install/wine-signal/noop-i386.c +2 -0
  38. package/docker/install/wine-signal/signal-state.c +105 -0
  39. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  40. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  41. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  42. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  43. package/docker/install/wine-signal/wine +4 -0
  44. package/docker/runner.ts +131 -0
  45. package/package.json +53 -4
  46. package/python/build.py +655 -0
  47. package/python/codesys_online.py +300 -0
  48. package/python/test_build.py +444 -0
  49. package/python/test_codesys_online.py +170 -0
  50. package/python/test_installer_packages.py +216 -0
  51. package/python/test_installer_verification.py +307 -0
  52. package/python/test_worker.py +142 -0
  53. package/python/worker.py +89 -0
  54. package/runtime/install.sh +74 -0
  55. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  56. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  57. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  58. package/src/artifacts.ts +77 -0
  59. package/src/build.ts +87 -0
  60. package/src/cli.ts +116 -0
  61. package/src/config.ts +12 -0
  62. package/src/controller-online.ts +143 -0
  63. package/src/controller-session.ts +229 -0
  64. package/src/diagnostics.ts +66 -0
  65. package/src/docker-build.ts +148 -0
  66. package/src/docker-context.ts +35 -0
  67. package/src/docker-run.ts +104 -0
  68. package/src/host/codesys-command.ts +45 -0
  69. package/src/host/codesys-host.ts +145 -0
  70. package/src/host/codesys-worker.ts +345 -0
  71. package/src/host/windows-path.ts +73 -0
  72. package/src/index.ts +26 -0
  73. package/src/io.ts +127 -0
  74. package/src/libraries.ts +166 -0
  75. package/src/package-path.ts +5 -0
  76. package/src/run.ts +133 -0
  77. package/src/runtime-status.ts +21 -0
  78. package/src/sources.ts +248 -0
  79. package/src/st/declarations.ts +229 -0
  80. package/src/st/lexer.ts +125 -0
  81. package/src/st/parse.ts +202 -0
  82. package/src/st/reader.ts +50 -0
package/src/sources.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import type { ResolvedConfig } from "./config";
4
+ import { type ObjectKind, type ParsedSource, parseSource } from "./st/parse";
5
+ import { bindStructuredTextIo } from "./io.ts";
6
+
7
+ export type SourceObject = ParsedSource & {
8
+ source: string;
9
+ folder: string[];
10
+ children: SourceObject[];
11
+ };
12
+ type Files = {
13
+ combined: string[];
14
+ declaration: string[];
15
+ implementation: string[];
16
+ };
17
+ const prefixes: Record<string, ObjectKind> = {
18
+ FB: "function_block",
19
+ PRG: "program",
20
+ FUN: "function",
21
+ F: "function",
22
+ I: "interface",
23
+ ST: "dut",
24
+ E: "dut",
25
+ T: "dut",
26
+ U: "dut",
27
+ GVL: "gvl",
28
+ };
29
+ const childFolders: Record<string, ObjectKind> = {
30
+ methods: "method",
31
+ properties: "property",
32
+ actions: "action",
33
+ };
34
+ const canOwn = (kind: ObjectKind) =>
35
+ ["program", "function_block", "interface", "property"].includes(kind);
36
+ const assertUnique = (objects: SourceObject[], scope: string) => {
37
+ const seen = new Map<string, string>();
38
+ for (const object of objects) {
39
+ const key = object.name.toLowerCase();
40
+ const previous = seen.get(key);
41
+ if (previous) {
42
+ throw new Error(
43
+ `Duplicate IEC name ${object.name} in ${scope}: ${previous} and ${object.source}`,
44
+ );
45
+ }
46
+ seen.set(key, object.source);
47
+ assertUnique(object.children, `${scope}.${object.name}`);
48
+ }
49
+ };
50
+
51
+ const readUnit = async (
52
+ name: string,
53
+ files: Files,
54
+ role?: ObjectKind,
55
+ interfaceMember = false,
56
+ ): Promise<ParsedSource & { source: string }> => {
57
+ if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(name)) {
58
+ throw new Error(`Invalid IEC object name: ${name}`);
59
+ }
60
+ for (const [part, candidates] of Object.entries(files)) {
61
+ if (candidates.length > 1) {
62
+ throw new Error(`Ambiguous ${part} for ${name}: ${candidates.join(", ")}`);
63
+ }
64
+ }
65
+ const combined = files.combined[0];
66
+ const declaration = files.declaration[0];
67
+ const implementation = files.implementation[0];
68
+ if (combined && (declaration || implementation)) {
69
+ throw new Error(`Both combined and split sources exist for ${name}`);
70
+ }
71
+ const path = combined ?? declaration ?? implementation;
72
+ if (!path) {
73
+ throw new Error(`Missing source for ${name}`);
74
+ }
75
+ if (role === "action") {
76
+ if (!combined) {
77
+ throw new Error(`Actions use one implementation-only .st file: ${path}`);
78
+ }
79
+ return {
80
+ kind: "action",
81
+ name,
82
+ declaration: "",
83
+ implementation: await readFile(combined, "utf8"),
84
+ source: combined,
85
+ };
86
+ }
87
+ if (role === "get" || role === "set") {
88
+ if (!combined) {
89
+ throw new Error(`Property accessors use one .st file: ${path}`);
90
+ }
91
+ const body = await readFile(combined, "utf8");
92
+ const header = `METHOD ${name}\n`;
93
+ const parsed = parseSource(`${header}${body}\nEND_METHOD`, {
94
+ filename: combined,
95
+ });
96
+ return {
97
+ ...parsed,
98
+ kind: role,
99
+ name,
100
+ declaration: parsed.declaration.slice(header.length),
101
+ source: combined,
102
+ };
103
+ }
104
+ if (!combined && !declaration) {
105
+ throw new Error(`Missing declaration for ${path}`);
106
+ }
107
+ const sourcePath = combined ?? declaration ?? path;
108
+ const parsed = parseSource(await readFile(sourcePath, "utf8"), {
109
+ filename: sourcePath,
110
+ name,
111
+ declarationOnly: !combined || interfaceMember,
112
+ });
113
+ if (parsed.name.toLowerCase() !== name.toLowerCase()) {
114
+ throw new Error(`Source name ${name} does not match declaration ${parsed.name}: ${sourcePath}`);
115
+ }
116
+ if (role && parsed.kind !== role) {
117
+ throw new Error(`Expected ${role}, found ${parsed.kind}: ${sourcePath}`);
118
+ }
119
+ if (!role && ["method", "property"].includes(parsed.kind)) {
120
+ throw new Error(`Child object has no enclosing POU: ${sourcePath}`);
121
+ }
122
+ const prefix = /^([A-Z]+)_/.exec(name.toUpperCase())?.[1];
123
+ if (!role && prefix && prefixes[prefix] && prefixes[prefix] !== parsed.kind) {
124
+ throw new Error(`Prefix ${prefix}_ conflicts with ${parsed.kind}: ${sourcePath}`);
125
+ }
126
+ const declarationOnly =
127
+ ["dut", "gvl", "interface", "property"].includes(parsed.kind) || interfaceMember;
128
+ if (declarationOnly && implementation) {
129
+ throw new Error(`This object has no implementation: ${implementation}`);
130
+ }
131
+ if (!combined && !declarationOnly && !implementation) {
132
+ throw new Error(`Missing implementation for ${sourcePath}`);
133
+ }
134
+ return {
135
+ ...parsed,
136
+ ...(implementation ? { implementation: await readFile(implementation, "utf8") } : {}),
137
+ source: sourcePath,
138
+ };
139
+ };
140
+
141
+ /** Folder placement organizes the IDE; it never changes IEC names or execution order. */
142
+ export const loadSources = async (
143
+ config: Pick<ResolvedConfig, "sourceDir" | "entry" | "io" | "tasks">,
144
+ ) => {
145
+ const objects: SourceObject[] = [];
146
+ const visit = async (
147
+ directory: string,
148
+ folder: string[],
149
+ parent?: SourceObject,
150
+ role?: ObjectKind,
151
+ ): Promise<void> => {
152
+ const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) =>
153
+ a.name.localeCompare(b.name, "en"),
154
+ );
155
+ const groups = new Map<string, { name: string; files: Files }>();
156
+ for (const entry of entries) {
157
+ if (entry.isSymbolicLink()) {
158
+ throw new Error(`Source symlinks are not supported: ${resolve(directory, entry.name)}`);
159
+ }
160
+ if (!entry.isFile() || !/\.st$/i.test(entry.name)) {
161
+ continue;
162
+ }
163
+ const stem = entry.name.slice(0, -3);
164
+ const suffix = /^(.*)\.(declaration|implementation)$/.exec(stem);
165
+ const generic = ["source", "declaration", "implementation"].includes(stem);
166
+ const name = generic ? basename(directory) : (suffix?.[1] ?? stem);
167
+ const section = suffix?.[2] ?? stem;
168
+ const part = section === "declaration" || section === "implementation" ? section : "combined";
169
+ const key = name.toLowerCase();
170
+ const group = groups.get(key) ?? {
171
+ name,
172
+ files: { combined: [], declaration: [], implementation: [] },
173
+ };
174
+ group.files[part].push(resolve(directory, entry.name));
175
+ groups.set(key, group);
176
+ }
177
+ const units: SourceObject[] = [];
178
+ const ownKey = basename(directory).toLowerCase();
179
+ // Read an object's own source before its Get/Set files, which have no POU header.
180
+ const ordered = [...groups].sort(([a], [b]) => Number(b === ownKey) - Number(a === ownKey));
181
+ for (const [key, { name, files }] of ordered) {
182
+ const ownProperty = units.find(
183
+ (unit) => unit.kind === "property" && unit.name.toLowerCase() === ownKey,
184
+ );
185
+ const accessor = ownProperty && (key === "get" || key === "set") ? key : undefined;
186
+ const unit: SourceObject = {
187
+ ...(await readUnit(
188
+ accessor ? `${accessor[0]?.toUpperCase()}${accessor.slice(1)}` : name,
189
+ files,
190
+ accessor ?? role,
191
+ parent?.kind === "interface",
192
+ )),
193
+ folder: parent ? [] : folder,
194
+ children: [],
195
+ };
196
+ if (accessor && ownProperty) {
197
+ if (parent?.kind === "interface") {
198
+ throw new Error(`Interface properties cannot implement accessors: ${unit.source}`);
199
+ }
200
+ ownProperty.children.push(unit);
201
+ } else {
202
+ units.push(unit);
203
+ if (parent) {
204
+ parent.children.push(unit);
205
+ } else {
206
+ objects.push(unit);
207
+ }
208
+ }
209
+ }
210
+ const own = units.find((unit) => unit.name.toLowerCase() === ownKey && canOwn(unit.kind));
211
+ if (own && !parent) {
212
+ own.folder = folder.slice(0, -1);
213
+ }
214
+ for (const entry of entries.filter((entry) => entry.isDirectory())) {
215
+ const childRole = childFolders[entry.name];
216
+ if (own && childRole) {
217
+ if (own.kind === "property" || (own.kind === "interface" && childRole === "action")) {
218
+ throw new Error(`Unsupported ${entry.name} under ${own.name}`);
219
+ }
220
+ await visit(resolve(directory, entry.name), [], own, childRole);
221
+ } else if (parent) {
222
+ await visit(resolve(directory, entry.name), [], parent, role);
223
+ } else {
224
+ await visit(resolve(directory, entry.name), [...folder, entry.name]);
225
+ }
226
+ }
227
+ };
228
+ await visit(resolve(config.sourceDir), []);
229
+ assertUnique(objects, "Application");
230
+ if (config.io) bindStructuredTextIo(objects, config.io);
231
+ const entry = objects.find((object) => object.source === resolve(config.entry));
232
+ if (!entry) {
233
+ throw new Error(`Entry is not a discovered source: ${config.entry}`);
234
+ }
235
+ if (entry.kind !== "program") {
236
+ throw new Error(`Entry must be a PROGRAM: ${config.entry}`);
237
+ }
238
+ if (config.tasks) {
239
+ const programs = new Set(
240
+ objects.filter((object) => object.kind === "program").map((object) => object.name),
241
+ );
242
+ for (const task of config.tasks)
243
+ for (const program of task.programs)
244
+ if (!programs.has(program))
245
+ throw new Error(`Task ${task.name} references an undiscovered PROGRAM ${program}`);
246
+ }
247
+ return { entry: entry.name, objects };
248
+ };
@@ -0,0 +1,229 @@
1
+ import type { Token } from "./lexer";
2
+ import type { TokenReader } from "./reader";
3
+
4
+ export const objectKinds = {
5
+ PROGRAM: "program",
6
+ FUNCTION_BLOCK: "function_block",
7
+ FUNCTIONBLOCK: "function_block",
8
+ FUNCTION: "function",
9
+ METHOD: "method",
10
+ INTERFACE: "interface",
11
+ PROPERTY: "property",
12
+ } as const;
13
+
14
+ const modifiers = new Set([
15
+ "PUBLIC",
16
+ "PRIVATE",
17
+ "PROTECTED",
18
+ "INTERNAL",
19
+ "FINAL",
20
+ "ABSTRACT",
21
+ "OVERRIDE",
22
+ ]);
23
+ export const globalSections = new Set(["VAR_GLOBAL", "VAR_CONFIG"]);
24
+ export const variableSections = new Set([
25
+ ...globalSections,
26
+ "VAR",
27
+ "VAR_INPUT",
28
+ "VAR_OUTPUT",
29
+ "VAR_IN_OUT",
30
+ "VAR_TEMP",
31
+ "VAR_STAT",
32
+ "VAR_INST",
33
+ "VAR_EXTERNAL",
34
+ "VAR_ACCESS",
35
+ "VAR_GENERIC",
36
+ ]);
37
+ const closingObjects = new Set(Object.keys(objectKinds).map((kind) => `END_${kind}`));
38
+ export const declarationKeywords = new Set([
39
+ ...Object.keys(objectKinds),
40
+ ...closingObjects,
41
+ ...variableSections,
42
+ "END_VAR",
43
+ "TYPE",
44
+ "END_TYPE",
45
+ "STRUCT",
46
+ "END_STRUCT",
47
+ "UNION",
48
+ "END_UNION",
49
+ "ACTION",
50
+ "END_ACTION",
51
+ "CONFIGURATION",
52
+ "END_CONFIGURATION",
53
+ ]);
54
+
55
+ const conditionalDirective = (token?: Token) => {
56
+ if (token?.kind === "pragma") {
57
+ return /^\{\s*(IF|ELSIF|ELSE|END_IF)\b/i.exec(token.value)?.[1]?.toUpperCase();
58
+ }
59
+ };
60
+ const isDeclarationPragma = (token?: Token) =>
61
+ token?.kind === "pragma" && !conditionalDirective(token);
62
+
63
+ export const readDeclarationPragmas = (reader: TokenReader) => {
64
+ while (isDeclarationPragma(reader.peek())) {
65
+ reader.take();
66
+ }
67
+ };
68
+
69
+ export const readDeclarationSemicolon = (reader: TokenReader) => {
70
+ const token = reader.peek();
71
+ // A semicolon on the next line may be the body's first empty statement.
72
+ // Only attach it to a declaration when it terminates that same line.
73
+ if (token?.value === ";" && !/[\r\n]/.test(reader.source.slice(reader.end, token.start))) {
74
+ reader.take();
75
+ }
76
+ };
77
+
78
+ export const readIdentifier = (reader: TokenReader) => {
79
+ const token = reader.take();
80
+ if (token.kind !== "word" || declarationKeywords.has(token.value)) {
81
+ reader.fail("Expected an IEC identifier", token);
82
+ }
83
+ return reader.source.slice(token.start, token.end);
84
+ };
85
+
86
+ const isOverrideModifier = (reader: TokenReader) => {
87
+ // OVERRIDE is also an established IEC name (e.g. OSCAT's function).
88
+ // A modifier needs a following name on the same line, not a body statement.
89
+ const name = reader.peek(1);
90
+ return (
91
+ name?.kind === "word" &&
92
+ !declarationKeywords.has(name.value) &&
93
+ !["(", "[", ":=", ".", "^"].includes(reader.peek(2)?.value ?? "") &&
94
+ !/[\r\n]/.test(reader.source.slice(reader.peek()?.end, name.start))
95
+ );
96
+ };
97
+
98
+ export const readModifiers = (reader: TokenReader) => {
99
+ while (modifiers.has(reader.peek()?.value ?? "")) {
100
+ if (reader.peek()?.value === "OVERRIDE" && !isOverrideModifier(reader)) {
101
+ return;
102
+ }
103
+ reader.take();
104
+ }
105
+ };
106
+
107
+ const typeDelimiters = { "(": ")", "[": "]", "<": ">" } as const;
108
+
109
+ const readDelimitedType = (reader: TokenReader, open: keyof typeof typeDelimiters) => {
110
+ reader.expect(open);
111
+ const closings = [typeDelimiters[open]];
112
+ while (closings.length) {
113
+ const token = reader.take();
114
+ if (token.kind === "string" || token.kind === "pragma") {
115
+ continue;
116
+ }
117
+ if (token.value === "(" || token.value === "[") {
118
+ closings.push(typeDelimiters[token.value]);
119
+ } else if (token.value === ")" || token.value === "]") {
120
+ if (closings.pop() !== token.value) {
121
+ reader.fail(`Mismatched delimiter ${token.value}`, token);
122
+ }
123
+ } else if (open === "<" && closings.at(-1) === ">") {
124
+ // Inside parentheses, < and > are comparisons, not generic delimiters:
125
+ // FB_Base<(Size := BOOL_TO_INT(1 < 2))>.
126
+ if (token.value === "<") {
127
+ closings.push(">");
128
+ }
129
+ if (token.value === ">") {
130
+ closings.pop();
131
+ }
132
+ }
133
+ if (declarationKeywords.has(token.value) || token.value === ";") {
134
+ reader.fail(`Unclosed ${open}`, token);
135
+ }
136
+ }
137
+ };
138
+
139
+ export const readType = (reader: TokenReader): string => {
140
+ const start = reader.peek()?.start ?? reader.source.length;
141
+ // Prefix types can nest arbitrarily; iteration avoids a JavaScript stack limit.
142
+ while (true) {
143
+ if (["POINTER", "REFERENCE", "REF_TO"].includes(reader.peek()?.value ?? "")) {
144
+ if (reader.take().value !== "REF_TO") {
145
+ reader.expect("TO");
146
+ }
147
+ continue;
148
+ }
149
+ if (reader.consume("ARRAY")) {
150
+ readDelimitedType(reader, "[");
151
+ reader.expect("OF");
152
+ continue;
153
+ }
154
+ break;
155
+ }
156
+ readIdentifier(reader);
157
+ while (reader.consume(".")) {
158
+ readIdentifier(reader);
159
+ }
160
+ for (const open of ["(", "[", "<"] as const) {
161
+ if (reader.peek()?.value === open) {
162
+ readDelimitedType(reader, open);
163
+ }
164
+ }
165
+ return reader.source.slice(start, reader.end);
166
+ };
167
+
168
+ export const readVariableSection = (reader: TokenReader) => {
169
+ reader.take();
170
+ const conditionals: { hasElse: boolean }[] = [];
171
+ while (reader.peek()?.value !== "END_VAR") {
172
+ const token = reader.take();
173
+ if (variableSections.has(token.value) || closingObjects.has(token.value)) {
174
+ reader.fail("Missing END_VAR", token);
175
+ }
176
+ const directive = conditionalDirective(token);
177
+ if (!directive) {
178
+ continue;
179
+ }
180
+ if (directive === "IF") {
181
+ conditionals.push({ hasElse: false });
182
+ continue;
183
+ }
184
+ const current = conditionals.at(-1);
185
+ if (!current) {
186
+ return reader.fail("Unmatched conditional pragma", token);
187
+ }
188
+ if (directive === "END_IF") {
189
+ conditionals.pop();
190
+ continue;
191
+ }
192
+ if (current.hasElse) {
193
+ reader.fail("Conditional branch after ELSE", token);
194
+ }
195
+ current.hasElse = directive === "ELSE";
196
+ }
197
+ if (conditionals.length) {
198
+ reader.fail("Conditional pragmas must remain inside one variable section");
199
+ }
200
+ reader.take();
201
+ readDeclarationSemicolon(reader);
202
+ };
203
+
204
+ export const readDeclarationSections = (reader: TokenReader) => {
205
+ while (reader.peek()) {
206
+ // Pragmas belong to the declaration only when another declaration follows.
207
+ // Look ahead without consuming them so a pragma-only body stays intact.
208
+ let ahead = 0;
209
+ while (isDeclarationPragma(reader.peek(ahead))) {
210
+ ahead++;
211
+ }
212
+ const next = reader.peek(ahead)?.value ?? "";
213
+ if (variableSections.has(next)) {
214
+ readDeclarationPragmas(reader);
215
+ readVariableSection(reader);
216
+ continue;
217
+ }
218
+ if (next !== "EXTENDS" && next !== "IMPLEMENTS") {
219
+ return;
220
+ }
221
+ // CODESYS generic FBs can place these clauses after VAR_GENERIC.
222
+ readDeclarationPragmas(reader);
223
+ reader.take();
224
+ readType(reader);
225
+ while (reader.consume(",")) {
226
+ readType(reader);
227
+ }
228
+ }
229
+ };
@@ -0,0 +1,125 @@
1
+ export type Token = {
2
+ kind: "word" | "string" | "pragma" | "symbol";
3
+ value: string;
4
+ start: number;
5
+ end: number;
6
+ };
7
+
8
+ export class StSyntaxError extends Error {
9
+ constructor(source: string, offset: number, filename: string, message: string) {
10
+ const lines = source.slice(0, offset).split(/\r\n|\r|\n/);
11
+ super(`${filename}:${lines.length}:${(lines.at(-1)?.length ?? 0) + 1}: ${message}`);
12
+ this.name = "StSyntaxError";
13
+ }
14
+ }
15
+
16
+ /** Keep original offsets: splitting must preserve comments, pragmas and source spelling. */
17
+ export const tokenize = (source: string, filename = "<source>"): Token[] => {
18
+ const tokens: Token[] = [];
19
+ let offset = 0;
20
+ const fail = (start: number, message: string): never => {
21
+ throw new StSyntaxError(source, start, filename, message);
22
+ };
23
+ const quoted = () => {
24
+ const start = offset;
25
+ const quote = source[offset++];
26
+ while (offset < source.length) {
27
+ // IEC strings escape characters (including quotes and $ itself) with $.
28
+ if (source[offset] === "$") {
29
+ offset += 2;
30
+ } else if (source[offset++] === quote) {
31
+ return;
32
+ }
33
+ }
34
+ fail(start, "Unterminated string");
35
+ };
36
+ while (offset < source.length) {
37
+ const start = offset;
38
+ const character = source[offset] ?? "";
39
+ if (/\s|\uFEFF/.test(character)) {
40
+ offset++;
41
+ continue;
42
+ }
43
+ if (source.startsWith("//", offset)) {
44
+ while (offset < source.length && !/[\r\n]/.test(source[offset] ?? "")) {
45
+ offset++;
46
+ }
47
+ continue;
48
+ }
49
+ if (source.startsWith("(*", offset)) {
50
+ offset += 2;
51
+ let depth = 1;
52
+ while (offset < source.length && depth > 0) {
53
+ if (source.startsWith("(*", offset)) {
54
+ depth++;
55
+ offset += 2;
56
+ } else if (source.startsWith("*)", offset)) {
57
+ depth--;
58
+ offset += 2;
59
+ } else {
60
+ offset++;
61
+ }
62
+ }
63
+ if (depth) {
64
+ fail(start, "Unterminated block comment");
65
+ }
66
+ continue;
67
+ }
68
+ if (character === "'" || character === '"') {
69
+ quoted();
70
+ tokens.push({
71
+ kind: "string",
72
+ value: source.slice(start, offset),
73
+ start,
74
+ end: offset,
75
+ });
76
+ continue;
77
+ }
78
+ if (character === "{") {
79
+ offset++;
80
+ while (offset < source.length && source[offset] !== "}") {
81
+ if (source[offset] === "'" || source[offset] === '"') {
82
+ quoted();
83
+ } else {
84
+ offset++;
85
+ }
86
+ }
87
+ if (offset === source.length) {
88
+ fail(start, "Unterminated pragma");
89
+ }
90
+ offset++;
91
+ tokens.push({
92
+ kind: "pragma",
93
+ value: source.slice(start, offset),
94
+ start,
95
+ end: offset,
96
+ });
97
+ continue;
98
+ }
99
+ if (/[A-Za-z_]/.test(character)) {
100
+ offset++;
101
+ while (/[A-Za-z_0-9]/.test(source[offset] ?? "")) {
102
+ offset++;
103
+ }
104
+ tokens.push({
105
+ kind: "word",
106
+ value: source.slice(start, offset).toUpperCase(),
107
+ start,
108
+ end: offset,
109
+ });
110
+ continue;
111
+ }
112
+ offset++;
113
+ const pair = source.slice(start, offset + 1);
114
+ if ([":=", "=>", "..", "<=", ">=", "<>", "**"].includes(pair)) {
115
+ offset++;
116
+ }
117
+ tokens.push({
118
+ kind: "symbol",
119
+ value: source.slice(start, offset),
120
+ start,
121
+ end: offset,
122
+ });
123
+ }
124
+ return tokens;
125
+ };