xcdn 1.0.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/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +35 -0
- package/src/ast.js +409 -0
- package/src/error.js +106 -0
- package/src/index.js +36 -0
- package/src/lexer.js +475 -0
- package/src/parser.js +427 -0
- package/src/serializer.js +392 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xCDN - JavaScript Implementation
|
|
3
|
+
*
|
|
4
|
+
* A parser and serializer for the xCDN data format.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Re-export AST types
|
|
8
|
+
export * from './ast.js';
|
|
9
|
+
|
|
10
|
+
// Re-export error types
|
|
11
|
+
export * from './error.js';
|
|
12
|
+
|
|
13
|
+
// Re-export lexer
|
|
14
|
+
export * from './lexer.js';
|
|
15
|
+
|
|
16
|
+
// Re-export parser
|
|
17
|
+
export { Parser, parseStr, parseReader } from './parser.js';
|
|
18
|
+
|
|
19
|
+
// Re-export serializer
|
|
20
|
+
export {
|
|
21
|
+
Format,
|
|
22
|
+
Serializer,
|
|
23
|
+
toStringPretty,
|
|
24
|
+
toStringCompact,
|
|
25
|
+
toStringWithFormat
|
|
26
|
+
} from './serializer.js';
|
|
27
|
+
|
|
28
|
+
// Default export for convenience
|
|
29
|
+
import { parseStr } from './parser.js';
|
|
30
|
+
import { toStringPretty, toStringCompact } from './serializer.js';
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
parse: parseStr,
|
|
34
|
+
stringify: toStringPretty,
|
|
35
|
+
stringifyCompact: toStringCompact
|
|
36
|
+
};
|
package/src/lexer.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xCDN Lexer Module
|
|
3
|
+
* Tokenization for the xCDN format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Span, XCDNError, ErrorKind } from './error.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Token types
|
|
10
|
+
*/
|
|
11
|
+
export const TokenType = {
|
|
12
|
+
// Structure
|
|
13
|
+
LBRACE: 'LBRACE', // {
|
|
14
|
+
RBRACE: 'RBRACE', // }
|
|
15
|
+
LBRACKET: 'LBRACKET', // [
|
|
16
|
+
RBRACKET: 'RBRACKET', // ]
|
|
17
|
+
LPAREN: 'LPAREN', // (
|
|
18
|
+
RPAREN: 'RPAREN', // )
|
|
19
|
+
COLON: 'COLON', // :
|
|
20
|
+
COMMA: 'COMMA', // ,
|
|
21
|
+
DOLLAR: 'DOLLAR', // $
|
|
22
|
+
HASH: 'HASH', // #
|
|
23
|
+
AT: 'AT', // @
|
|
24
|
+
|
|
25
|
+
// Keywords
|
|
26
|
+
TRUE: 'TRUE',
|
|
27
|
+
FALSE: 'FALSE',
|
|
28
|
+
NULL: 'NULL',
|
|
29
|
+
|
|
30
|
+
// Values
|
|
31
|
+
IDENT: 'IDENT', // Unquoted identifiers
|
|
32
|
+
INT: 'INT', // Integer numbers
|
|
33
|
+
FLOAT: 'FLOAT', // Floating point numbers
|
|
34
|
+
STRING: 'STRING', // Strings "..."
|
|
35
|
+
TRIPLE_STRING: 'TRIPLE_STRING', // Strings """..."""
|
|
36
|
+
|
|
37
|
+
// Typed strings
|
|
38
|
+
D_QUOTED: 'D_QUOTED', // d"decimal"
|
|
39
|
+
B_QUOTED: 'B_QUOTED', // b"base64"
|
|
40
|
+
U_QUOTED: 'U_QUOTED', // u"uuid"
|
|
41
|
+
T_QUOTED: 'T_QUOTED', // t"datetime"
|
|
42
|
+
R_QUOTED: 'R_QUOTED', // r"duration"
|
|
43
|
+
|
|
44
|
+
// End of input
|
|
45
|
+
EOF: 'EOF',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Single token
|
|
50
|
+
*/
|
|
51
|
+
export class Token {
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} kind - Token type (from TokenType)
|
|
54
|
+
* @param {Span} span - Position in source
|
|
55
|
+
* @param {*} value - Extracted value (optional)
|
|
56
|
+
*/
|
|
57
|
+
constructor(kind, span, value = null) {
|
|
58
|
+
this.kind = kind;
|
|
59
|
+
this.span = span;
|
|
60
|
+
this.value = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lexer for xCDN
|
|
66
|
+
*/
|
|
67
|
+
export class Lexer {
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} source - Source code
|
|
70
|
+
*/
|
|
71
|
+
constructor(source) {
|
|
72
|
+
this.source = source;
|
|
73
|
+
this.pos = 0;
|
|
74
|
+
this.line = 1;
|
|
75
|
+
this.column = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Creates a Span for the current position
|
|
80
|
+
* @returns {Span}
|
|
81
|
+
*/
|
|
82
|
+
span() {
|
|
83
|
+
return new Span(this.pos, this.line, this.column);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reads the next character without consuming it
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
peek() {
|
|
91
|
+
if (this.pos >= this.source.length) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return this.source[this.pos];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Looks at the second character ahead without consuming
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
peekNext() {
|
|
102
|
+
if (this.pos + 1 >= this.source.length) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return this.source[this.pos + 1];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Consumes and returns the next character
|
|
110
|
+
* @returns {string|null}
|
|
111
|
+
*/
|
|
112
|
+
bump() {
|
|
113
|
+
if (this.pos >= this.source.length) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const ch = this.source[this.pos];
|
|
117
|
+
this.pos++;
|
|
118
|
+
if (ch === '\n') {
|
|
119
|
+
this.line++;
|
|
120
|
+
this.column = 1;
|
|
121
|
+
} else {
|
|
122
|
+
this.column++;
|
|
123
|
+
}
|
|
124
|
+
return ch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Skips whitespace and comments
|
|
129
|
+
*/
|
|
130
|
+
skipWsAndComments() {
|
|
131
|
+
while (true) {
|
|
132
|
+
const ch = this.peek();
|
|
133
|
+
if (ch === null) break;
|
|
134
|
+
|
|
135
|
+
// Whitespace
|
|
136
|
+
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
|
|
137
|
+
this.bump();
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Comments
|
|
142
|
+
if (ch === '/') {
|
|
143
|
+
const next = this.peekNext();
|
|
144
|
+
if (next === '/') {
|
|
145
|
+
// Line comment
|
|
146
|
+
this.bump(); // /
|
|
147
|
+
this.bump(); // /
|
|
148
|
+
while (this.peek() !== null && this.peek() !== '\n') {
|
|
149
|
+
this.bump();
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
} else if (next === '*') {
|
|
153
|
+
// Block comment
|
|
154
|
+
this.bump(); // /
|
|
155
|
+
this.bump(); // *
|
|
156
|
+
while (true) {
|
|
157
|
+
const c = this.bump();
|
|
158
|
+
if (c === null) {
|
|
159
|
+
throw new XCDNError(ErrorKind.Eof, this.span(), 'Unterminated block comment');
|
|
160
|
+
}
|
|
161
|
+
if (c === '*' && this.peek() === '/') {
|
|
162
|
+
this.bump(); // /
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reads an identifier or keyword
|
|
176
|
+
* @returns {Token}
|
|
177
|
+
*/
|
|
178
|
+
readIdent() {
|
|
179
|
+
const startSpan = this.span();
|
|
180
|
+
let value = '';
|
|
181
|
+
|
|
182
|
+
while (true) {
|
|
183
|
+
const ch = this.peek();
|
|
184
|
+
if (ch === null) break;
|
|
185
|
+
if (this.isIdentContinue(ch)) {
|
|
186
|
+
value += this.bump();
|
|
187
|
+
} else {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check keywords
|
|
193
|
+
if (value === 'true') {
|
|
194
|
+
return new Token(TokenType.TRUE, startSpan, true);
|
|
195
|
+
} else if (value === 'false') {
|
|
196
|
+
return new Token(TokenType.FALSE, startSpan, false);
|
|
197
|
+
} else if (value === 'null') {
|
|
198
|
+
return new Token(TokenType.NULL, startSpan, null);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new Token(TokenType.IDENT, startSpan, value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Checks if the character can start an identifier
|
|
206
|
+
* @param {string} ch
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
isIdentStart(ch) {
|
|
210
|
+
return (ch >= 'a' && ch <= 'z') ||
|
|
211
|
+
(ch >= 'A' && ch <= 'Z') ||
|
|
212
|
+
ch === '_';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Checks if the character can continue an identifier
|
|
217
|
+
* @param {string} ch
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
isIdentContinue(ch) {
|
|
221
|
+
return this.isIdentStart(ch) ||
|
|
222
|
+
(ch >= '0' && ch <= '9') ||
|
|
223
|
+
ch === '-';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reads a number (integer or float)
|
|
228
|
+
* @returns {Token}
|
|
229
|
+
*/
|
|
230
|
+
readNumber() {
|
|
231
|
+
const startSpan = this.span();
|
|
232
|
+
let value = '';
|
|
233
|
+
let isFloat = false;
|
|
234
|
+
|
|
235
|
+
// Optional sign
|
|
236
|
+
if (this.peek() === '+' || this.peek() === '-') {
|
|
237
|
+
value += this.bump();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Integer part
|
|
241
|
+
while (this.peek() !== null && this.peek() >= '0' && this.peek() <= '9') {
|
|
242
|
+
value += this.bump();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Decimal part
|
|
246
|
+
if (this.peek() === '.' && this.peekNext() >= '0' && this.peekNext() <= '9') {
|
|
247
|
+
isFloat = true;
|
|
248
|
+
value += this.bump(); // .
|
|
249
|
+
while (this.peek() !== null && this.peek() >= '0' && this.peek() <= '9') {
|
|
250
|
+
value += this.bump();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Exponent
|
|
255
|
+
if (this.peek() === 'e' || this.peek() === 'E') {
|
|
256
|
+
isFloat = true;
|
|
257
|
+
value += this.bump(); // e/E
|
|
258
|
+
if (this.peek() === '+' || this.peek() === '-') {
|
|
259
|
+
value += this.bump();
|
|
260
|
+
}
|
|
261
|
+
while (this.peek() !== null && this.peek() >= '0' && this.peek() <= '9') {
|
|
262
|
+
value += this.bump();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (isFloat) {
|
|
267
|
+
const floatVal = parseFloat(value);
|
|
268
|
+
if (isNaN(floatVal)) {
|
|
269
|
+
throw new XCDNError(ErrorKind.InvalidNumber, startSpan, value);
|
|
270
|
+
}
|
|
271
|
+
return new Token(TokenType.FLOAT, startSpan, floatVal);
|
|
272
|
+
} else {
|
|
273
|
+
// Try BigInt for large numbers
|
|
274
|
+
try {
|
|
275
|
+
const intVal = BigInt(value);
|
|
276
|
+
return new Token(TokenType.INT, startSpan, intVal);
|
|
277
|
+
} catch {
|
|
278
|
+
throw new XCDNError(ErrorKind.InvalidNumber, startSpan, value);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Reads a normal string "..."
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
readStringContent() {
|
|
288
|
+
let value = '';
|
|
289
|
+
|
|
290
|
+
while (true) {
|
|
291
|
+
const ch = this.bump();
|
|
292
|
+
if (ch === null) {
|
|
293
|
+
throw new XCDNError(ErrorKind.Eof, this.span(), 'Unterminated string');
|
|
294
|
+
}
|
|
295
|
+
if (ch === '"') {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (ch === '\\') {
|
|
299
|
+
const escaped = this.bump();
|
|
300
|
+
if (escaped === null) {
|
|
301
|
+
throw new XCDNError(ErrorKind.Eof, this.span(), 'Unterminated escape sequence');
|
|
302
|
+
}
|
|
303
|
+
switch (escaped) {
|
|
304
|
+
case '"': value += '"'; break;
|
|
305
|
+
case '\\': value += '\\'; break;
|
|
306
|
+
case '/': value += '/'; break;
|
|
307
|
+
case 'b': value += '\b'; break;
|
|
308
|
+
case 'f': value += '\f'; break;
|
|
309
|
+
case 'n': value += '\n'; break;
|
|
310
|
+
case 'r': value += '\r'; break;
|
|
311
|
+
case 't': value += '\t'; break;
|
|
312
|
+
case 'u': {
|
|
313
|
+
// Unicode escape \uXXXX
|
|
314
|
+
let hex = '';
|
|
315
|
+
for (let i = 0; i < 4; i++) {
|
|
316
|
+
const h = this.bump();
|
|
317
|
+
if (h === null || !this.isHexDigit(h)) {
|
|
318
|
+
throw new XCDNError(ErrorKind.InvalidEscape, this.span(), '\\u requires 4 hex digits');
|
|
319
|
+
}
|
|
320
|
+
hex += h;
|
|
321
|
+
}
|
|
322
|
+
value += String.fromCharCode(parseInt(hex, 16));
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
default:
|
|
326
|
+
throw new XCDNError(ErrorKind.InvalidEscape, this.span(), `Unknown escape: \\${escaped}`);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
value += ch;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Checks if the character is a hexadecimal digit
|
|
338
|
+
* @param {string} ch
|
|
339
|
+
* @returns {boolean}
|
|
340
|
+
*/
|
|
341
|
+
isHexDigit(ch) {
|
|
342
|
+
return (ch >= '0' && ch <= '9') ||
|
|
343
|
+
(ch >= 'a' && ch <= 'f') ||
|
|
344
|
+
(ch >= 'A' && ch <= 'F');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Reads a triple string """..."""
|
|
349
|
+
* @returns {string}
|
|
350
|
+
*/
|
|
351
|
+
readTripleString() {
|
|
352
|
+
let value = '';
|
|
353
|
+
|
|
354
|
+
while (true) {
|
|
355
|
+
const ch = this.bump();
|
|
356
|
+
if (ch === null) {
|
|
357
|
+
throw new XCDNError(ErrorKind.Eof, this.span(), 'Unterminated triple-quoted string');
|
|
358
|
+
}
|
|
359
|
+
if (ch === '"' && this.peek() === '"' && this.peekNext() === '"') {
|
|
360
|
+
this.bump(); // "
|
|
361
|
+
this.bump(); // "
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
value += ch;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reads the next token
|
|
372
|
+
* @returns {Token}
|
|
373
|
+
*/
|
|
374
|
+
nextToken() {
|
|
375
|
+
this.skipWsAndComments();
|
|
376
|
+
|
|
377
|
+
const startSpan = this.span();
|
|
378
|
+
const ch = this.peek();
|
|
379
|
+
|
|
380
|
+
if (ch === null) {
|
|
381
|
+
return new Token(TokenType.EOF, startSpan);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Single structural characters
|
|
385
|
+
switch (ch) {
|
|
386
|
+
case '{':
|
|
387
|
+
this.bump();
|
|
388
|
+
return new Token(TokenType.LBRACE, startSpan);
|
|
389
|
+
case '}':
|
|
390
|
+
this.bump();
|
|
391
|
+
return new Token(TokenType.RBRACE, startSpan);
|
|
392
|
+
case '[':
|
|
393
|
+
this.bump();
|
|
394
|
+
return new Token(TokenType.LBRACKET, startSpan);
|
|
395
|
+
case ']':
|
|
396
|
+
this.bump();
|
|
397
|
+
return new Token(TokenType.RBRACKET, startSpan);
|
|
398
|
+
case '(':
|
|
399
|
+
this.bump();
|
|
400
|
+
return new Token(TokenType.LPAREN, startSpan);
|
|
401
|
+
case ')':
|
|
402
|
+
this.bump();
|
|
403
|
+
return new Token(TokenType.RPAREN, startSpan);
|
|
404
|
+
case ':':
|
|
405
|
+
this.bump();
|
|
406
|
+
return new Token(TokenType.COLON, startSpan);
|
|
407
|
+
case ',':
|
|
408
|
+
this.bump();
|
|
409
|
+
return new Token(TokenType.COMMA, startSpan);
|
|
410
|
+
case '$':
|
|
411
|
+
this.bump();
|
|
412
|
+
return new Token(TokenType.DOLLAR, startSpan);
|
|
413
|
+
case '#':
|
|
414
|
+
this.bump();
|
|
415
|
+
return new Token(TokenType.HASH, startSpan);
|
|
416
|
+
case '@':
|
|
417
|
+
this.bump();
|
|
418
|
+
return new Token(TokenType.AT, startSpan);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Strings
|
|
422
|
+
if (ch === '"') {
|
|
423
|
+
this.bump(); // opening "
|
|
424
|
+
|
|
425
|
+
// Check for triple-quoted string
|
|
426
|
+
if (this.peek() === '"' && this.peekNext() === '"') {
|
|
427
|
+
this.bump(); // "
|
|
428
|
+
this.bump(); // "
|
|
429
|
+
const value = this.readTripleString();
|
|
430
|
+
return new Token(TokenType.TRIPLE_STRING, startSpan, value);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const value = this.readStringContent();
|
|
434
|
+
return new Token(TokenType.STRING, startSpan, value);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Typed strings (d", b", u", t", r")
|
|
438
|
+
if ((ch === 'd' || ch === 'b' || ch === 'u' || ch === 't' || ch === 'r') &&
|
|
439
|
+
this.peekNext() === '"') {
|
|
440
|
+
const prefix = this.bump();
|
|
441
|
+
this.bump(); // "
|
|
442
|
+
const value = this.readStringContent();
|
|
443
|
+
|
|
444
|
+
switch (prefix) {
|
|
445
|
+
case 'd': return new Token(TokenType.D_QUOTED, startSpan, value);
|
|
446
|
+
case 'b': return new Token(TokenType.B_QUOTED, startSpan, value);
|
|
447
|
+
case 'u': return new Token(TokenType.U_QUOTED, startSpan, value);
|
|
448
|
+
case 't': return new Token(TokenType.T_QUOTED, startSpan, value);
|
|
449
|
+
case 'r': return new Token(TokenType.R_QUOTED, startSpan, value);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Identifiers
|
|
454
|
+
if (this.isIdentStart(ch)) {
|
|
455
|
+
return this.readIdent();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Numbers
|
|
459
|
+
if ((ch >= '0' && ch <= '9') ||
|
|
460
|
+
ch === '.' ||
|
|
461
|
+
ch === '+' ||
|
|
462
|
+
ch === '-') {
|
|
463
|
+
// Verify it's a valid number
|
|
464
|
+
if (ch === '.' && !(this.peekNext() >= '0' && this.peekNext() <= '9')) {
|
|
465
|
+
throw new XCDNError(ErrorKind.InvalidToken, startSpan, ch);
|
|
466
|
+
}
|
|
467
|
+
if ((ch === '+' || ch === '-') && !(this.peekNext() >= '0' && this.peekNext() <= '9') && this.peekNext() !== '.') {
|
|
468
|
+
throw new XCDNError(ErrorKind.InvalidToken, startSpan, ch);
|
|
469
|
+
}
|
|
470
|
+
return this.readNumber();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
throw new XCDNError(ErrorKind.InvalidToken, startSpan, ch);
|
|
474
|
+
}
|
|
475
|
+
}
|