@tsplc/build 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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1696 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1683 -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 +18 -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/edge-gateway/Dockerfile +17 -0
  17. package/docker/entrypoint.sh +10 -0
  18. package/docker/install/NOTICE.md +21 -0
  19. package/docker/install/core.sh +36 -0
  20. package/docker/install/decode.c +12 -0
  21. package/docker/install/device/Runtime.ico +0 -0
  22. package/docker/install/device/Runtime.png +0 -0
  23. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  24. package/docker/install/extract.py +43 -0
  25. package/docker/install/helper-launcher.c +46 -0
  26. package/docker/install/install.sh +94 -0
  27. package/docker/install/prerequisites.sh +15 -0
  28. package/docker/install/verify.py +133 -0
  29. package/docker/install/verify.sh +34 -0
  30. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  31. package/docker/install/wine-ncrypt/README.md +28 -0
  32. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  33. package/docker/install/wine-ncrypt/main.c +619 -0
  34. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  35. package/docker/install/wine-signal/README.md +22 -0
  36. package/docker/install/wine-signal/build.sh +10 -0
  37. package/docker/install/wine-signal/check.sh +34 -0
  38. package/docker/install/wine-signal/noop-i386.c +2 -0
  39. package/docker/install/wine-signal/signal-state.c +105 -0
  40. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  41. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  42. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  43. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  44. package/docker/install/wine-signal/wine +4 -0
  45. package/docker/runner.ts +156 -0
  46. package/package.json +53 -4
  47. package/python/build.py +660 -0
  48. package/python/codesys_login.py +100 -0
  49. package/python/codesys_online.py +327 -0
  50. package/python/test_build.py +444 -0
  51. package/python/test_codesys_login.py +120 -0
  52. package/python/test_codesys_online.py +184 -0
  53. package/python/test_installer_packages.py +216 -0
  54. package/python/test_installer_verification.py +307 -0
  55. package/python/test_worker.py +142 -0
  56. package/python/worker.py +102 -0
  57. package/runtime/install.sh +74 -0
  58. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  59. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  60. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  61. package/src/artifacts.ts +77 -0
  62. package/src/build.ts +87 -0
  63. package/src/cli.ts +116 -0
  64. package/src/config.ts +12 -0
  65. package/src/controller-online.ts +297 -0
  66. package/src/controller-session.ts +298 -0
  67. package/src/diagnostics.ts +68 -0
  68. package/src/docker-build.ts +163 -0
  69. package/src/docker-context.ts +35 -0
  70. package/src/docker-run.ts +103 -0
  71. package/src/host/codesys-command.ts +45 -0
  72. package/src/host/codesys-host.ts +145 -0
  73. package/src/host/codesys-worker.ts +348 -0
  74. package/src/host/windows-path.ts +73 -0
  75. package/src/index.ts +43 -0
  76. package/src/io.ts +127 -0
  77. package/src/libraries.ts +166 -0
  78. package/src/package-path.ts +5 -0
  79. package/src/run.ts +133 -0
  80. package/src/runtime-status.ts +21 -0
  81. package/src/sources.ts +248 -0
  82. package/src/st/declarations.ts +229 -0
  83. package/src/st/lexer.ts +125 -0
  84. package/src/st/parse.ts +202 -0
  85. package/src/st/reader.ts +50 -0
@@ -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
+ };
@@ -0,0 +1,202 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ declarationKeywords,
4
+ globalSections,
5
+ objectKinds,
6
+ readDeclarationPragmas,
7
+ readDeclarationSections,
8
+ readDeclarationSemicolon,
9
+ readIdentifier,
10
+ readModifiers,
11
+ readType,
12
+ readVariableSection,
13
+ variableSections,
14
+ } from "./declarations";
15
+ import type { Token } from "./lexer";
16
+ import { createTokenReader, type TokenReader } from "./reader";
17
+
18
+ export type ObjectKind =
19
+ (typeof objectKinds)[keyof typeof objectKinds] | "dut" | "gvl" | "action" | "get" | "set";
20
+ export type ParsedSource = {
21
+ kind: ObjectKind;
22
+ name: string;
23
+ declaration: string;
24
+ implementation?: string;
25
+ returnType?: string;
26
+ dutType?: "structure" | "enumeration" | "union" | "alias";
27
+ persistent?: boolean;
28
+ };
29
+
30
+ type ParseOptions = {
31
+ filename?: string;
32
+ declarationOnly?: boolean;
33
+ name?: string;
34
+ };
35
+
36
+ const dutTypes: Record<string, NonNullable<ParsedSource["dutType"]>> = {
37
+ STRUCT: "structure",
38
+ UNION: "union",
39
+ "(": "enumeration",
40
+ };
41
+
42
+ const parseDut = (reader: TokenReader): ParsedSource => {
43
+ reader.expect("TYPE");
44
+ readModifiers(reader);
45
+ const name = readIdentifier(reader);
46
+ if (reader.consume("EXTENDS")) {
47
+ readType(reader);
48
+ }
49
+ reader.expect(":");
50
+ readDeclarationPragmas(reader);
51
+ const dutType = dutTypes[reader.peek()?.value ?? ""] ?? "alias";
52
+ while (reader.peek()?.value !== "END_TYPE") {
53
+ if (reader.peek()?.value === "TYPE") {
54
+ reader.fail("Only one TYPE declaration is allowed per file");
55
+ }
56
+ reader.take();
57
+ }
58
+ reader.take();
59
+ reader.consume(";");
60
+ if (reader.peek()) {
61
+ reader.fail("Unexpected content after END_TYPE");
62
+ }
63
+ return { kind: "dut", name, declaration: reader.source, dutType };
64
+ };
65
+
66
+ const parseGlobals = (reader: TokenReader, options: ParseOptions): ParsedSource => {
67
+ const header = reader.peek();
68
+ let persistent = false;
69
+ while (reader.peek()) {
70
+ readDeclarationPragmas(reader);
71
+ if (!reader.peek()) {
72
+ break;
73
+ }
74
+ if (!globalSections.has(reader.peek()?.value ?? "")) {
75
+ reader.fail("Expected a global variable section");
76
+ }
77
+ const start = reader.index;
78
+ readVariableSection(reader);
79
+ // Only the section modifiers select the CODESYS persistent-list object.
80
+ // Comments, pragma strings, and variable names cannot select this role.
81
+ for (const token of reader.tokens.slice(start + 1, reader.index)) {
82
+ if (
83
+ token.kind !== "word" ||
84
+ !["CONSTANT", "RETAIN", "PERSISTENT", "NON_RETAIN"].includes(token.value)
85
+ ) {
86
+ break;
87
+ }
88
+ persistent ||= token.value === "PERSISTENT";
89
+ }
90
+ }
91
+ const name = options.name ?? basename(reader.filename, ".st");
92
+ if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(name)) {
93
+ reader.fail("Global list requires an IEC filename or an explicit name", header);
94
+ }
95
+ return {
96
+ kind: "gvl",
97
+ name,
98
+ declaration: reader.source,
99
+ ...(persistent && { persistent: true }),
100
+ };
101
+ };
102
+
103
+ const validateImplementation = (reader: TokenReader, tokens: Token[]) => {
104
+ for (const token of tokens) {
105
+ if (token.kind !== "word") {
106
+ continue;
107
+ }
108
+ if (variableSections.has(token.value) || token.value === "END_VAR") {
109
+ reader.fail(
110
+ "Variable declaration outside the declaration section; conditional declaration envelopes are not supported",
111
+ token,
112
+ );
113
+ }
114
+ if (declarationKeywords.has(token.value)) {
115
+ reader.fail(
116
+ "Nested objects must be separate child files, or the object terminator is mismatched",
117
+ token,
118
+ );
119
+ }
120
+ }
121
+ };
122
+
123
+ const splitObject = (
124
+ reader: TokenReader,
125
+ header: Token,
126
+ kind: ObjectKind,
127
+ options: ParseOptions,
128
+ ): Pick<ParsedSource, "declaration" | "implementation"> => {
129
+ const terminator =
130
+ header.value === "FUNCTIONBLOCK" ? "END_FUNCTION_BLOCK" : `END_${header.value}`;
131
+ const remaining = reader.tokens.slice(reader.index);
132
+ const terminatorIndex = remaining.findIndex(
133
+ (token) => token.kind === "word" && token.value === terminator,
134
+ );
135
+ // Even interfaces and properties need END_* in combined files. Only an explicit
136
+ // declaration-only input (e.g. a paired declaration.st) may omit it.
137
+ if (terminatorIndex < 0 && !options.declarationOnly) {
138
+ reader.fail(`Missing ${terminator}`);
139
+ }
140
+ const bodyTokens = terminatorIndex < 0 ? remaining : remaining.slice(0, terminatorIndex);
141
+ const [endToken, next, afterSemicolon] =
142
+ terminatorIndex < 0 ? [] : remaining.slice(terminatorIndex);
143
+ const semicolon = next?.value === ";" ? next : undefined;
144
+ const unexpected = semicolon ? afterSemicolon : next;
145
+ if (unexpected) {
146
+ reader.fail(`Unexpected content after ${terminator}`, unexpected);
147
+ }
148
+ validateImplementation(reader, bodyTokens);
149
+
150
+ const declarationOnly = options.declarationOnly || kind === "interface" || kind === "property";
151
+ if (declarationOnly && bodyTokens.length) {
152
+ reader.fail("Unexpected implementation in a declaration-only object", bodyTokens[0]);
153
+ }
154
+
155
+ const declaration = reader.source.slice(0, reader.end);
156
+ // Remove only END_* and its optional semicolon. Keep whitespace and trailing
157
+ // comments in the body, or in the declaration for objects without a body.
158
+ const body =
159
+ reader.source.slice(reader.end, endToken?.start) +
160
+ (endToken ? reader.source.slice((semicolon ?? endToken).end) : "");
161
+ if (declarationOnly) {
162
+ return { declaration: declaration + body };
163
+ }
164
+ return { declaration, implementation: body };
165
+ };
166
+
167
+ const parsePou = (reader: TokenReader, options: ParseOptions): ParsedSource => {
168
+ const header = reader.take();
169
+ if (!(header.value in objectKinds)) {
170
+ reader.fail(`Unsupported object header ${header.value}`, header);
171
+ }
172
+ const kind = objectKinds[header.value as keyof typeof objectKinds];
173
+ readModifiers(reader);
174
+ const name = readIdentifier(reader);
175
+ const returnType = reader.consume(":") ? readType(reader) : undefined;
176
+ if (returnType === undefined && kind === "property") {
177
+ reader.fail(`${header.value} requires a return type`);
178
+ }
179
+ // Exported declarations may terminate the header itself with a semicolon.
180
+ readDeclarationSemicolon(reader);
181
+ readDeclarationSections(reader);
182
+ return {
183
+ kind,
184
+ name,
185
+ ...(returnType === undefined ? {} : { returnType }),
186
+ ...splitObject(reader, header, kind, options),
187
+ };
188
+ };
189
+
190
+ /** Parse only the object envelope and declarations. CODESYS validates executable ST. */
191
+ export const parseSource = (source: string, options: ParseOptions = {}): ParsedSource => {
192
+ const reader = createTokenReader(source, options.filename ?? "<source>");
193
+ readDeclarationPragmas(reader);
194
+ const header = reader.peek();
195
+ if (header?.value === "TYPE") {
196
+ return parseDut(reader);
197
+ }
198
+ if (header && globalSections.has(header.value)) {
199
+ return parseGlobals(reader, options);
200
+ }
201
+ return parsePou(reader, options);
202
+ };
@@ -0,0 +1,50 @@
1
+ import { StSyntaxError, type Token, tokenize } from "./lexer";
2
+
3
+ /** Traverse tokens without losing the source offsets needed for an exact split. */
4
+ export const createTokenReader = (source: string, filename: string) => {
5
+ const tokens = tokenize(source, filename);
6
+ let index = 0;
7
+ const peek = (ahead = 0) => tokens[index + ahead];
8
+ const fail = (message: string, token = peek()): never => {
9
+ throw new StSyntaxError(source, token?.start ?? source.length, filename, message);
10
+ };
11
+ const take = (): Token => {
12
+ const token = peek();
13
+ if (!token) {
14
+ return fail("Unexpected end of declaration");
15
+ }
16
+ index++;
17
+ return token;
18
+ };
19
+ const expect = (value: string) => {
20
+ if (peek()?.value !== value) {
21
+ fail(`Expected ${value}`);
22
+ }
23
+ return take();
24
+ };
25
+ const consume = (value: string) => {
26
+ if (peek()?.value === value) {
27
+ return take();
28
+ }
29
+ };
30
+
31
+ return {
32
+ source,
33
+ filename,
34
+ tokens,
35
+ peek,
36
+ take,
37
+ expect,
38
+ consume,
39
+ fail,
40
+ get index() {
41
+ return index;
42
+ },
43
+ // The declaration ends at the last consumed token, before any body whitespace.
44
+ get end() {
45
+ return tokens[index - 1]?.end ?? 0;
46
+ },
47
+ };
48
+ };
49
+
50
+ export type TokenReader = ReturnType<typeof createTokenReader>;