@elwood-lang/core 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.
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Elwood — a functional JSON transformation DSL.
3
+ *
4
+ * This is the TypeScript implementation, behaviorally identical
5
+ * to the .NET reference engine.
6
+ */
7
+
8
+ import { parseExpression, parseScript } from './parser.js';
9
+ import { evaluateExpression, evaluateScript } from './evaluator.js';
10
+ import type { Diagnostic } from './lexer.js';
11
+
12
+ export { TokenKind } from './token.js';
13
+ export type { Token, SourceSpan } from './token.js';
14
+ export type { ElwoodExpression, ScriptNode } from './ast.js';
15
+ export type { Diagnostic } from './lexer.js';
16
+
17
+ export interface ElwoodResult {
18
+ value: unknown;
19
+ success: boolean;
20
+ diagnostics: ElwoodDiagnostic[];
21
+ }
22
+
23
+ export interface ElwoodDiagnostic {
24
+ severity: 'error' | 'warning' | 'info';
25
+ message: string;
26
+ line?: number;
27
+ column?: number;
28
+ suggestion?: string;
29
+ }
30
+
31
+ /**
32
+ * Evaluate a single Elwood expression against input data.
33
+ */
34
+ export function evaluate(expression: string, input: unknown): ElwoodResult {
35
+ try {
36
+ const { ast, diagnostics } = parseExpression(expression);
37
+ if (diagnostics.some(d => d.severity === 'error')) {
38
+ return { value: null, success: false, diagnostics: diagnostics.map(toDiag) };
39
+ }
40
+ const value = evaluateExpression(ast, input);
41
+ return { value, success: true, diagnostics: diagnostics.map(toDiag) };
42
+ } catch (err: any) {
43
+ return {
44
+ value: null,
45
+ success: false,
46
+ diagnostics: [{ severity: 'error', message: err.message }],
47
+ };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Execute an Elwood script (with let bindings and return) against input data.
53
+ */
54
+ export function execute(script: string, input: unknown): ElwoodResult {
55
+ try {
56
+ const { ast, diagnostics } = parseScript(script);
57
+ if (diagnostics.some(d => d.severity === 'error')) {
58
+ return { value: null, success: false, diagnostics: diagnostics.map(toDiag) };
59
+ }
60
+ const value = evaluateScript(ast, input);
61
+ return { value, success: true, diagnostics: diagnostics.map(toDiag) };
62
+ } catch (err: any) {
63
+ return {
64
+ value: null,
65
+ success: false,
66
+ diagnostics: [{ severity: 'error', message: err.message }],
67
+ };
68
+ }
69
+ }
70
+
71
+ function toDiag(d: Diagnostic): ElwoodDiagnostic {
72
+ return {
73
+ severity: d.severity,
74
+ message: d.message,
75
+ line: d.span?.line,
76
+ column: d.span?.column,
77
+ };
78
+ }
package/src/lexer.ts ADDED
@@ -0,0 +1,300 @@
1
+ import { Token, TokenKind, SourceSpan } from './token.js';
2
+
3
+ export interface Diagnostic {
4
+ severity: 'error' | 'warning' | 'info';
5
+ message: string;
6
+ span: SourceSpan;
7
+ }
8
+
9
+ const KEYWORDS = new Map<string, TokenKind>([
10
+ ['let', TokenKind.Let],
11
+ ['if', TokenKind.If],
12
+ ['then', TokenKind.Then],
13
+ ['else', TokenKind.Else],
14
+ ['match', TokenKind.Match],
15
+ ['return', TokenKind.Return],
16
+ ['true', TokenKind.TrueLiteral],
17
+ ['false', TokenKind.FalseLiteral],
18
+ ['null', TokenKind.NullLiteral],
19
+ ['asc', TokenKind.Asc],
20
+ ['desc', TokenKind.Desc],
21
+ ['on', TokenKind.On],
22
+ ['equals', TokenKind.Equals],
23
+ ['into', TokenKind.Into],
24
+ ['from', TokenKind.From],
25
+ ['memo', TokenKind.Memo],
26
+ ['_', TokenKind.Underscore],
27
+ ]);
28
+
29
+ const SINGLE_CHAR: Record<string, TokenKind> = {
30
+ '.': TokenKind.Dot,
31
+ '|': TokenKind.Pipe,
32
+ ',': TokenKind.Comma,
33
+ ':': TokenKind.Colon,
34
+ '(': TokenKind.LeftParen,
35
+ ')': TokenKind.RightParen,
36
+ '[': TokenKind.LeftBracket,
37
+ ']': TokenKind.RightBracket,
38
+ '{': TokenKind.LeftBrace,
39
+ '}': TokenKind.RightBrace,
40
+ '*': TokenKind.Star,
41
+ '+': TokenKind.Plus,
42
+ '-': TokenKind.Minus,
43
+ '/': TokenKind.Slash,
44
+ '<': TokenKind.LessThan,
45
+ '>': TokenKind.GreaterThan,
46
+ '!': TokenKind.Bang,
47
+ '=': TokenKind.Assign,
48
+ };
49
+
50
+ const TWO_CHAR: Record<string, TokenKind> = {
51
+ '=>': TokenKind.FatArrow,
52
+ '==': TokenKind.EqualEqual,
53
+ '!=': TokenKind.BangEqual,
54
+ '<=': TokenKind.LessThanOrEqual,
55
+ '>=': TokenKind.GreaterThanOrEqual,
56
+ '&&': TokenKind.AmpersandAmpersand,
57
+ '||': TokenKind.PipePipe,
58
+ '..': TokenKind.DotDot,
59
+ };
60
+
61
+ function isDigit(c: string): boolean {
62
+ return c >= '0' && c <= '9';
63
+ }
64
+
65
+ function isLetter(c: string): boolean {
66
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
67
+ }
68
+
69
+ function isLetterOrDigit(c: string): boolean {
70
+ return isLetter(c) || isDigit(c);
71
+ }
72
+
73
+ /**
74
+ * Tokenizes Elwood source text into a sequence of tokens.
75
+ */
76
+ export function tokenize(source: string): { tokens: Token[]; diagnostics: Diagnostic[] } {
77
+ let pos = 0;
78
+ let line = 1;
79
+ let col = 1;
80
+ const tokens: Token[] = [];
81
+ const diagnostics: Diagnostic[] = [];
82
+
83
+ function current(): string {
84
+ return pos < source.length ? source[pos] : '\0';
85
+ }
86
+
87
+ function peek(offset: number): string | undefined {
88
+ return pos + offset < source.length ? source[pos + offset] : undefined;
89
+ }
90
+
91
+ function advance(): void {
92
+ pos++;
93
+ col++;
94
+ }
95
+
96
+ function makeToken(kind: TokenKind, text: string, start: number, end: number, startLine: number, startCol: number): Token {
97
+ return { kind, text, span: { start, end, line: startLine, column: startCol } };
98
+ }
99
+
100
+ function skipWhitespaceAndComments(): void {
101
+ while (pos < source.length) {
102
+ const c = source[pos];
103
+
104
+ if (c === ' ' || c === '\t' || c === '\r') {
105
+ advance();
106
+ continue;
107
+ }
108
+
109
+ if (c === '\n') {
110
+ advance();
111
+ line++;
112
+ col = 1;
113
+ continue;
114
+ }
115
+
116
+ // Single-line comment: //
117
+ if (c === '/' && peek(1) === '/') {
118
+ while (pos < source.length && source[pos] !== '\n') advance();
119
+ continue;
120
+ }
121
+
122
+ // Multi-line comment: /* ... */
123
+ if (c === '/' && peek(1) === '*') {
124
+ advance(); advance();
125
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/')) {
126
+ if (source[pos] === '\n') { line++; col = 1; }
127
+ advance();
128
+ }
129
+ if (pos < source.length - 1) { advance(); advance(); }
130
+ continue;
131
+ }
132
+
133
+ break;
134
+ }
135
+ }
136
+
137
+ function readString(quote: string): Token {
138
+ const start = pos;
139
+ const startLine = line;
140
+ const startCol = col;
141
+ advance(); // skip opening quote
142
+
143
+ let value = '';
144
+ while (pos < source.length && source[pos] !== quote) {
145
+ if (source[pos] === '\\' && pos + 1 < source.length) {
146
+ advance();
147
+ const escaped = source[pos];
148
+ switch (escaped) {
149
+ case 'n': value += '\n'; break;
150
+ case 't': value += '\t'; break;
151
+ case 'r': value += '\r'; break;
152
+ case '\\': value += '\\'; break;
153
+ default:
154
+ if (escaped === quote) value += quote;
155
+ else value += escaped;
156
+ }
157
+ advance();
158
+ } else {
159
+ value += source[pos];
160
+ advance();
161
+ }
162
+ }
163
+
164
+ if (pos < source.length) advance(); // skip closing quote
165
+
166
+ return makeToken(TokenKind.StringLiteral, value, start, pos, startLine, startCol);
167
+ }
168
+
169
+ function readInterpolatedString(): Token {
170
+ const start = pos;
171
+ const startLine = line;
172
+ const startCol = col;
173
+ advance(); // skip opening backtick
174
+
175
+ let value = '';
176
+ let depth = 0;
177
+ while (pos < source.length && !(source[pos] === '`' && depth === 0)) {
178
+ if (source[pos] === '{') depth++;
179
+ else if (source[pos] === '}') depth--;
180
+ value += source[pos];
181
+ advance();
182
+ }
183
+
184
+ if (pos < source.length) advance(); // skip closing backtick
185
+
186
+ return makeToken(TokenKind.Backtick, value, start, pos, startLine, startCol);
187
+ }
188
+
189
+ function readNumber(): Token {
190
+ const start = pos;
191
+ const startLine = line;
192
+ const startCol = col;
193
+
194
+ while (pos < source.length && (isDigit(source[pos]) || source[pos] === '.')) {
195
+ advance();
196
+ }
197
+
198
+ return makeToken(TokenKind.NumberLiteral, source.slice(start, pos), start, pos, startLine, startCol);
199
+ }
200
+
201
+ function readIdentifierOrKeyword(): Token {
202
+ const start = pos;
203
+ const startLine = line;
204
+ const startCol = col;
205
+
206
+ while (pos < source.length && (isLetterOrDigit(source[pos]) || source[pos] === '_')) {
207
+ advance();
208
+ }
209
+
210
+ const text = source.slice(start, pos);
211
+ const kind = KEYWORDS.get(text) ?? TokenKind.Identifier;
212
+
213
+ return makeToken(kind, text, start, pos, startLine, startCol);
214
+ }
215
+
216
+ function readToken(): Token {
217
+ const start = pos;
218
+ const startLine = line;
219
+ const startCol = col;
220
+ const c = source[pos];
221
+
222
+ // String literals
223
+ if (c === '"' || c === "'") return readString(c);
224
+
225
+ // Backtick string (interpolated)
226
+ if (c === '`') return readInterpolatedString();
227
+
228
+ // Numbers
229
+ if (isDigit(c) || (c === '.' && peek(1) !== undefined && isDigit(peek(1)!))) {
230
+ return readNumber();
231
+ }
232
+
233
+ // $ (dollar — start of path)
234
+ if (c === '$') {
235
+ advance();
236
+ if (current() === '.') {
237
+ if (peek(1) === '.') {
238
+ advance(); advance();
239
+ return makeToken(TokenKind.DotDot, '$..', start, pos, startLine, startCol);
240
+ }
241
+ advance();
242
+ return makeToken(TokenKind.DollarDot, '$.', start, pos, startLine, startCol);
243
+ }
244
+ return makeToken(TokenKind.Dollar, '$', start, pos, startLine, startCol);
245
+ }
246
+
247
+ // Identifiers and keywords
248
+ if (isLetter(c) || c === '_') return readIdentifierOrKeyword();
249
+
250
+ // Three-character operators
251
+ if (pos + 2 < source.length && source.slice(pos, pos + 3) === '...') {
252
+ advance(); advance(); advance();
253
+ return makeToken(TokenKind.Spread, '...', start, pos, startLine, startCol);
254
+ }
255
+
256
+ // Two-character operators
257
+ if (pos + 1 < source.length) {
258
+ const two = source.slice(pos, pos + 2);
259
+ const kind2 = TWO_CHAR[two];
260
+ if (kind2 !== undefined) {
261
+ advance(); advance();
262
+ return makeToken(kind2, two, start, pos, startLine, startCol);
263
+ }
264
+ }
265
+
266
+ // Single-character operators
267
+ advance();
268
+ const kind1 = SINGLE_CHAR[c];
269
+ if (kind1 !== undefined) {
270
+ return makeToken(kind1, c, start, pos, startLine, startCol);
271
+ }
272
+
273
+ // Newline (already advanced)
274
+ if (c === '\n') {
275
+ return makeToken(TokenKind.Eof, '', start, pos, startLine, startCol); // skipped by caller
276
+ }
277
+
278
+ diagnostics.push({
279
+ severity: 'error',
280
+ message: `Unexpected character '${c}'`,
281
+ span: { start, end: pos, line: startLine, column: startCol },
282
+ });
283
+
284
+ return makeToken(TokenKind.Eof, '', start, pos, startLine, startCol);
285
+ }
286
+
287
+ // Main loop
288
+ while (pos < source.length) {
289
+ skipWhitespaceAndComments();
290
+ if (pos >= source.length) break;
291
+
292
+ const token = readToken();
293
+ if (token.kind !== TokenKind.Eof || pos >= source.length) {
294
+ tokens.push(token);
295
+ }
296
+ }
297
+
298
+ tokens.push(makeToken(TokenKind.Eof, '', pos, pos, line, col));
299
+ return { tokens, diagnostics };
300
+ }