openagentflow 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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * OpenAgentFlow Lexer
3
+ *
4
+ * Performs lexical analysis on .oaf source text, producing a stream of tokens.
5
+ * This is the first stage of the compilation pipeline.
6
+ *
7
+ * Pipeline: Source Text → [Lexer] → Tokens → Parser → AST
8
+ */
9
+
10
+ // ─── Token Types ───────────────────────────────────────────────────────────────
11
+
12
+ export const TokenType = Object.freeze({
13
+ // Keywords
14
+ WORKFLOW: 'WORKFLOW',
15
+ AGENT: 'AGENT',
16
+ STATE: 'STATE',
17
+ FLOW: 'FLOW',
18
+ CONFIG: 'CONFIG',
19
+ START: 'START',
20
+ END: 'END',
21
+
22
+ // Type keywords
23
+ STRING_TYPE: 'STRING_TYPE',
24
+ INT_TYPE: 'INT_TYPE',
25
+ FLOAT_TYPE: 'FLOAT_TYPE',
26
+ BOOL_TYPE: 'BOOL_TYPE',
27
+ LIST_TYPE: 'LIST_TYPE',
28
+ MAP_TYPE: 'MAP_TYPE',
29
+
30
+ // Boolean literals
31
+ TRUE: 'TRUE',
32
+ FALSE: 'FALSE',
33
+
34
+ // Literals
35
+ STRING: 'STRING',
36
+ TRIPLE_STRING: 'TRIPLE_STRING',
37
+ INTEGER: 'INTEGER',
38
+ FLOAT: 'FLOAT',
39
+ IDENTIFIER: 'IDENTIFIER',
40
+
41
+ // Punctuation
42
+ LBRACE: 'LBRACE', // {
43
+ RBRACE: 'RBRACE', // }
44
+ LBRACKET: 'LBRACKET', // [
45
+ RBRACKET: 'RBRACKET', // ]
46
+ LPAREN: 'LPAREN', // (
47
+ RPAREN: 'RPAREN', // )
48
+ COLON: 'COLON', // :
49
+ COMMA: 'COMMA', // ,
50
+ ARROW: 'ARROW', // ->
51
+ AT: 'AT', // @
52
+
53
+ // Special
54
+ EOF: 'EOF',
55
+ });
56
+
57
+ const KEYWORDS = new Map([
58
+ ['workflow', TokenType.WORKFLOW],
59
+ ['agent', TokenType.AGENT],
60
+ ['state', TokenType.STATE],
61
+ ['flow', TokenType.FLOW],
62
+ ['config', TokenType.CONFIG],
63
+ ['start', TokenType.START],
64
+ ['end', TokenType.END],
65
+ ['string', TokenType.STRING_TYPE],
66
+ ['int', TokenType.INT_TYPE],
67
+ ['float', TokenType.FLOAT_TYPE],
68
+ ['bool', TokenType.BOOL_TYPE],
69
+ ['list', TokenType.LIST_TYPE],
70
+ ['map', TokenType.MAP_TYPE],
71
+ ['true', TokenType.TRUE],
72
+ ['false', TokenType.FALSE],
73
+ ]);
74
+
75
+ // ─── Token ─────────────────────────────────────────────────────────────────────
76
+
77
+ export class Token {
78
+ /**
79
+ * @param {string} type - One of TokenType values
80
+ * @param {string} value - Raw lexeme
81
+ * @param {number} line - 1-based line number
82
+ * @param {number} column - 1-based column number
83
+ */
84
+ constructor(type, value, line, column) {
85
+ this.type = type;
86
+ this.value = value;
87
+ this.line = line;
88
+ this.column = column;
89
+ }
90
+
91
+ toString() {
92
+ return `Token(${this.type}, ${JSON.stringify(this.value)}, ${this.line}:${this.column})`;
93
+ }
94
+ }
95
+
96
+ // ─── Lexer Error ───────────────────────────────────────────────────────────────
97
+
98
+ export class LexerError extends Error {
99
+ /**
100
+ * @param {string} message
101
+ * @param {number} line
102
+ * @param {number} column
103
+ */
104
+ constructor(message, line, column) {
105
+ super(`[ERROR] ${line}:${column} — ${message}`);
106
+ this.name = 'LexerError';
107
+ this.line = line;
108
+ this.column = column;
109
+ }
110
+ }
111
+
112
+ // ─── Lexer ─────────────────────────────────────────────────────────────────────
113
+
114
+ export class Lexer {
115
+ /**
116
+ * @param {string} source - The .oaf source text
117
+ * @param {string} [filename='<input>'] - Filename for diagnostics
118
+ */
119
+ constructor(source, filename = '<input>') {
120
+ this.source = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
121
+ this.filename = filename;
122
+ this.pos = 0;
123
+ this.line = 1;
124
+ this.column = 1;
125
+ this.tokens = [];
126
+ }
127
+
128
+ /**
129
+ * Tokenize the entire source and return the token array.
130
+ * @returns {Token[]}
131
+ */
132
+ tokenize() {
133
+ while (this.pos < this.source.length) {
134
+ this.skipWhitespaceAndComments();
135
+ if (this.pos >= this.source.length) break;
136
+
137
+ const ch = this.source[this.pos];
138
+
139
+ // Triple-quoted string
140
+ if (ch === '"' && this.source.substring(this.pos, this.pos + 3) === '"""') {
141
+ this.readTripleString();
142
+ continue;
143
+ }
144
+
145
+ // String literal
146
+ if (ch === '"') {
147
+ this.readString();
148
+ continue;
149
+ }
150
+
151
+ // Arrow ->
152
+ if (ch === '-' && this.peek(1) === '>') {
153
+ this.tokens.push(new Token(TokenType.ARROW, '->', this.line, this.column));
154
+ this.advance(2);
155
+ continue;
156
+ }
157
+
158
+ // Number (integer or float)
159
+ if (ch === '-' && this.isDigit(this.peek(1))) {
160
+ this.readNumber();
161
+ continue;
162
+ }
163
+ if (this.isDigit(ch)) {
164
+ this.readNumber();
165
+ continue;
166
+ }
167
+
168
+ // Identifier or keyword
169
+ if (this.isIdentStart(ch)) {
170
+ this.readIdentifier();
171
+ continue;
172
+ }
173
+
174
+ // Single-character punctuation
175
+ const punctMap = {
176
+ '{': TokenType.LBRACE,
177
+ '}': TokenType.RBRACE,
178
+ '[': TokenType.LBRACKET,
179
+ ']': TokenType.RBRACKET,
180
+ '(': TokenType.LPAREN,
181
+ ')': TokenType.RPAREN,
182
+ ':': TokenType.COLON,
183
+ ',': TokenType.COMMA,
184
+ '@': TokenType.AT,
185
+ };
186
+
187
+ if (punctMap[ch]) {
188
+ this.tokens.push(new Token(punctMap[ch], ch, this.line, this.column));
189
+ this.advance(1);
190
+ continue;
191
+ }
192
+
193
+ throw new LexerError(`Unexpected character: '${ch}'`, this.line, this.column);
194
+ }
195
+
196
+ this.tokens.push(new Token(TokenType.EOF, '', this.line, this.column));
197
+ return this.tokens;
198
+ }
199
+
200
+ // ── Helpers ────────────────────────────────────────────────────────────────
201
+
202
+ peek(offset = 0) {
203
+ const idx = this.pos + offset;
204
+ return idx < this.source.length ? this.source[idx] : '\0';
205
+ }
206
+
207
+ advance(count = 1) {
208
+ for (let i = 0; i < count; i++) {
209
+ if (this.source[this.pos] === '\n') {
210
+ this.line++;
211
+ this.column = 1;
212
+ } else {
213
+ this.column++;
214
+ }
215
+ this.pos++;
216
+ }
217
+ }
218
+
219
+ isDigit(ch) {
220
+ return ch >= '0' && ch <= '9';
221
+ }
222
+
223
+ isIdentStart(ch) {
224
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
225
+ }
226
+
227
+ isIdentPart(ch) {
228
+ if (ch === '-' && this.peek(1) !== '>') {
229
+ return true;
230
+ }
231
+ return this.isIdentStart(ch) || this.isDigit(ch);
232
+ }
233
+
234
+ skipWhitespaceAndComments() {
235
+ while (this.pos < this.source.length) {
236
+ const ch = this.source[this.pos];
237
+
238
+ // Whitespace
239
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
240
+ this.advance(1);
241
+ continue;
242
+ }
243
+
244
+ // Line comment
245
+ if (ch === '/' && this.peek(1) === '/') {
246
+ while (this.pos < this.source.length && this.source[this.pos] !== '\n') {
247
+ this.advance(1);
248
+ }
249
+ continue;
250
+ }
251
+
252
+ break;
253
+ }
254
+ }
255
+
256
+ readString() {
257
+ const startLine = this.line;
258
+ const startCol = this.column;
259
+ this.advance(1); // skip opening "
260
+
261
+ let value = '';
262
+ while (this.pos < this.source.length && this.source[this.pos] !== '"') {
263
+ if (this.source[this.pos] === '\\') {
264
+ this.advance(1);
265
+ const escaped = this.source[this.pos];
266
+ const escapeMap = { 'n': '\n', 't': '\t', '"': '"', '\\': '\\' };
267
+ value += escapeMap[escaped] ?? escaped;
268
+ } else {
269
+ value += this.source[this.pos];
270
+ }
271
+ this.advance(1);
272
+ }
273
+
274
+ if (this.pos >= this.source.length) {
275
+ throw new LexerError('Unterminated string literal', startLine, startCol);
276
+ }
277
+
278
+ this.advance(1); // skip closing "
279
+ this.tokens.push(new Token(TokenType.STRING, value, startLine, startCol));
280
+ }
281
+
282
+ readTripleString() {
283
+ const startLine = this.line;
284
+ const startCol = this.column;
285
+ this.advance(3); // skip opening """
286
+
287
+ let value = '';
288
+ while (this.pos < this.source.length) {
289
+ if (this.source.substring(this.pos, this.pos + 3) === '"""') {
290
+ break;
291
+ }
292
+ value += this.source[this.pos];
293
+ this.advance(1);
294
+ }
295
+
296
+ if (this.pos >= this.source.length) {
297
+ throw new LexerError('Unterminated triple-quoted string', startLine, startCol);
298
+ }
299
+
300
+ this.advance(3); // skip closing """
301
+
302
+ // Dedent the triple-quoted string
303
+ value = this.dedent(value);
304
+ this.tokens.push(new Token(TokenType.TRIPLE_STRING, value, startLine, startCol));
305
+ }
306
+
307
+ /**
308
+ * Strip common leading whitespace from a multi-line string (Python-style dedent).
309
+ */
310
+ dedent(text) {
311
+ const lines = text.split('\n');
312
+
313
+ // Remove leading/trailing blank lines
314
+ while (lines.length > 0 && lines[0].trim() === '') lines.shift();
315
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
316
+
317
+ if (lines.length === 0) return '';
318
+
319
+ // Find minimum indent (ignoring blank lines)
320
+ let minIndent = Infinity;
321
+ for (const line of lines) {
322
+ if (line.trim() === '') continue;
323
+ const indent = line.match(/^(\s*)/)[1].length;
324
+ if (indent < minIndent) minIndent = indent;
325
+ }
326
+
327
+ if (minIndent === Infinity) minIndent = 0;
328
+
329
+ return lines.map(l => l.slice(minIndent)).join('\n');
330
+ }
331
+
332
+ readNumber() {
333
+ const startLine = this.line;
334
+ const startCol = this.column;
335
+ let numStr = '';
336
+ let isFloat = false;
337
+
338
+ if (this.source[this.pos] === '-') {
339
+ numStr += '-';
340
+ this.advance(1);
341
+ }
342
+
343
+ while (this.pos < this.source.length && this.isDigit(this.source[this.pos])) {
344
+ numStr += this.source[this.pos];
345
+ this.advance(1);
346
+ }
347
+
348
+ if (this.pos < this.source.length && this.source[this.pos] === '.' && this.isDigit(this.peek(1))) {
349
+ isFloat = true;
350
+ numStr += '.';
351
+ this.advance(1);
352
+ while (this.pos < this.source.length && this.isDigit(this.source[this.pos])) {
353
+ numStr += this.source[this.pos];
354
+ this.advance(1);
355
+ }
356
+ }
357
+
358
+ const type = isFloat ? TokenType.FLOAT : TokenType.INTEGER;
359
+ this.tokens.push(new Token(type, numStr, startLine, startCol));
360
+ }
361
+
362
+ readIdentifier() {
363
+ const startLine = this.line;
364
+ const startCol = this.column;
365
+ let ident = '';
366
+
367
+ while (this.pos < this.source.length && this.isIdentPart(this.source[this.pos])) {
368
+ ident += this.source[this.pos];
369
+ this.advance(1);
370
+ }
371
+
372
+ const keywordType = KEYWORDS.get(ident);
373
+ const type = keywordType ?? TokenType.IDENTIFIER;
374
+ this.tokens.push(new Token(type, ident, startLine, startCol));
375
+ }
376
+ }