@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/dist/ast.d.ts +237 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -0
- package/dist/evaluator.d.ts +4 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +879 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer.d.ts +14 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +279 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +15 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +610 -0
- package/dist/parser.js.map +1 -0
- package/dist/scope.d.ts +13 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +27 -0
- package/dist/scope.js.map +1 -0
- package/dist/token.d.ts +65 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +66 -0
- package/dist/token.js.map +1 -0
- package/package.json +36 -0
- package/src/ast.ts +235 -0
- package/src/evaluator.ts +832 -0
- package/src/index.ts +78 -0
- package/src/lexer.ts +300 -0
- package/src/parser.ts +626 -0
- package/src/scope.ts +26 -0
- package/src/token.ts +87 -0
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
|
+
}
|