@wdprlib/ast 1.2.1 → 2.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,586 @@
1
+ /**
2
+ * Expression evaluator for Wikidot [[#expr]] and [[#ifexpr]]
3
+ *
4
+ * Supported:
5
+ * - Operators: +, -, *, /, % (modulo), ^ (power)
6
+ * - Comparison: <, >, <=, >=, =, !=
7
+ * - Logic: and, or, not
8
+ * - Functions: abs(), min(), max(), floor(), ceil(), round()
9
+ * - Parentheses for grouping
10
+ * - Negative numbers
11
+ *
12
+ * Expression limit: 256 characters (enforced by parser)
13
+ */
14
+
15
+ /**
16
+ * Set of string values considered falsy by Wikidot's `#if` construct.
17
+ * Case-insensitive after lowercasing and trimming.
18
+ */
19
+ const FALSE_VALUES = new Set(["false", "null", "", "0"]);
20
+
21
+ /**
22
+ * Determine whether a string value is truthy for Wikidot's `#if` construct.
23
+ *
24
+ * The value is lowercased and trimmed before checking against the set of
25
+ * known falsy strings (`"false"`, `"null"`, `""`, `"0"`).
26
+ *
27
+ * @param value - The condition string to check.
28
+ * @returns `true` if the value is not in the falsy set.
29
+ */
30
+ export function isTruthy(value: string): boolean {
31
+ return !FALSE_VALUES.has(value.toLowerCase().trim());
32
+ }
33
+
34
+ /** Maximum allowed expression length (enforced by the parser, checked here as a safety net). */
35
+ const MAX_EXPRESSION_LENGTH = 256;
36
+
37
+ /**
38
+ * Determine whether a number is truthy for logical operators (`and`, `or`, `not`).
39
+ *
40
+ * 0 and `NaN` are falsy; all other finite and infinite values are truthy.
41
+ *
42
+ * @param n - The number to check.
43
+ * @returns `true` if the number is non-zero and not `NaN`.
44
+ */
45
+ function isTruthyNum(n: number): boolean {
46
+ return n !== 0 && !Number.isNaN(n);
47
+ }
48
+
49
+ /**
50
+ * Result of evaluating a mathematical expression.
51
+ * Either a successful numeric value or an error message string.
52
+ */
53
+ export type ExprResult = { success: true; value: number } | { success: false; error: string };
54
+
55
+ /**
56
+ * Format a numeric expression result for display.
57
+ *
58
+ * Uses JavaScript's default `String(n)` so the full precision of the
59
+ * computed value is preserved (e.g. `1/3` becomes `"0.3333333333333333"`,
60
+ * matching the `Number` → `String` conversion rather than truncating to
61
+ * a fixed number of decimals). Used by both the inline renderer and the
62
+ * opener preprocess so the same expression produces the same string
63
+ * regardless of where it appears in the source.
64
+ */
65
+ export function formatExprValue(n: number): string {
66
+ return String(n);
67
+ }
68
+
69
+ /**
70
+ * Evaluate a mathematical expression string and return the result.
71
+ *
72
+ * The expression is tokenized, parsed with a recursive descent parser,
73
+ * and evaluated in a single pass. Errors produce Wikidot-compatible
74
+ * messages (e.g., `"division by zero"`, `"too many values in the stack"`).
75
+ *
76
+ * NaN and Infinity results are treated as division-by-zero errors.
77
+ *
78
+ * @param expr - The expression string to evaluate.
79
+ * @returns A success result with a numeric value, or an error result with a message.
80
+ */
81
+ export function evaluateExpression(expr: string): ExprResult {
82
+ try {
83
+ // Enforce length limit
84
+ if (expr.length > MAX_EXPRESSION_LENGTH) {
85
+ return { success: false, error: "expression too long" };
86
+ }
87
+ if (expr.trim() === "") {
88
+ return { success: false, error: "empty expression" };
89
+ }
90
+ const tokens = tokenize(expr);
91
+ if (tokens.length <= 1) {
92
+ // Only EOF token
93
+ return { success: false, error: "empty expression" };
94
+ }
95
+ const parser = new ExprParser(tokens);
96
+ const result = parser.parse();
97
+ // Treat NaN and Infinity as division by zero (Wikidot-compatible)
98
+ if (!Number.isFinite(result)) {
99
+ return { success: false, error: "division by zero" };
100
+ }
101
+ return { success: true, value: result };
102
+ } catch (e) {
103
+ const msg = e instanceof Error ? e.message : "unknown error";
104
+ return { success: false, error: msg };
105
+ }
106
+ }
107
+
108
+ /** Discriminant for expression tokens. */
109
+ type TokenKind =
110
+ | "NUMBER"
111
+ | "IDENTIFIER"
112
+ | "PLUS"
113
+ | "MINUS"
114
+ | "STAR"
115
+ | "SLASH"
116
+ | "PERCENT"
117
+ | "CARET"
118
+ | "LPAREN"
119
+ | "RPAREN"
120
+ | "COMMA"
121
+ | "LT"
122
+ | "GT"
123
+ | "LE"
124
+ | "GE"
125
+ | "EQ"
126
+ | "NE"
127
+ | "BANG"
128
+ | "EOF";
129
+
130
+ /** A single token produced by the expression tokenizer. */
131
+ interface ExprToken {
132
+ /** The type of this token. */
133
+ kind: TokenKind;
134
+ /** Numeric value for NUMBER tokens; string representation for all others. */
135
+ value: string | number;
136
+ }
137
+
138
+ /**
139
+ * Tokenize a mathematical expression string into a sequence of tokens.
140
+ *
141
+ * Handles numbers (including decimals), identifiers (function names and
142
+ * keywords like `and`/`or`/`not`), two-character operators (`<=`, `>=`,
143
+ * `!=`, `<>`), and single-character operators. Unknown characters throw
144
+ * an error.
145
+ *
146
+ * @param expr - The expression string to tokenize.
147
+ * @returns Array of tokens, always ending with an EOF token.
148
+ * @throws {Error} On encountering an unknown character or invalid number.
149
+ */
150
+ function tokenize(expr: string): ExprToken[] {
151
+ const tokens: ExprToken[] = [];
152
+ let i = 0;
153
+
154
+ while (i < expr.length) {
155
+ const ch = expr[i]!;
156
+
157
+ // Skip whitespace
158
+ if (/\s/.test(ch)) {
159
+ i++;
160
+ continue;
161
+ }
162
+
163
+ // Number (including decimals)
164
+ if (/\d/.test(ch) || (ch === "." && /\d/.test(expr[i + 1] ?? ""))) {
165
+ let numStr = "";
166
+ let hasDot = false;
167
+ while (i < expr.length) {
168
+ const c = expr[i]!;
169
+ if (c === ".") {
170
+ if (hasDot) break; // Only one decimal point allowed
171
+ hasDot = true;
172
+ } else if (!/\d/.test(c)) {
173
+ break;
174
+ }
175
+ numStr += c;
176
+ i++;
177
+ }
178
+ const num = parseFloat(numStr);
179
+ if (!Number.isFinite(num)) {
180
+ throw new Error("Invalid number");
181
+ }
182
+ tokens.push({ kind: "NUMBER", value: num });
183
+ continue;
184
+ }
185
+
186
+ // Identifier (function names, keywords like and/or/not)
187
+ if (/[a-zA-Z_]/.test(ch)) {
188
+ let id = "";
189
+ while (i < expr.length) {
190
+ const c = expr[i]!;
191
+ if (!/[a-zA-Z0-9_]/.test(c)) break;
192
+ id += c;
193
+ i++;
194
+ }
195
+ tokens.push({ kind: "IDENTIFIER", value: id.toLowerCase() });
196
+ continue;
197
+ }
198
+
199
+ // Two-character operators
200
+ if (ch === "<" && expr[i + 1] === "=") {
201
+ tokens.push({ kind: "LE", value: "<=" });
202
+ i += 2;
203
+ continue;
204
+ }
205
+ if (ch === ">" && expr[i + 1] === "=") {
206
+ tokens.push({ kind: "GE", value: ">=" });
207
+ i += 2;
208
+ continue;
209
+ }
210
+ if (ch === "!" && expr[i + 1] === "=") {
211
+ tokens.push({ kind: "NE", value: "!=" });
212
+ i += 2;
213
+ continue;
214
+ }
215
+ if (ch === "<" && expr[i + 1] === ">") {
216
+ tokens.push({ kind: "NE", value: "<>" });
217
+ i += 2;
218
+ continue;
219
+ }
220
+
221
+ // Unary logical NOT (Wikidot's `!` operator). The two-character `!=`
222
+ // has already been handled above, so a bare `!` here is always
223
+ // unary not.
224
+ if (ch === "!") {
225
+ tokens.push({ kind: "BANG", value: "!" });
226
+ i++;
227
+ continue;
228
+ }
229
+
230
+ // Single-character operators
231
+ switch (ch) {
232
+ case "+":
233
+ tokens.push({ kind: "PLUS", value: "+" });
234
+ break;
235
+ case "-":
236
+ tokens.push({ kind: "MINUS", value: "-" });
237
+ break;
238
+ case "*":
239
+ tokens.push({ kind: "STAR", value: "*" });
240
+ break;
241
+ case "/":
242
+ tokens.push({ kind: "SLASH", value: "/" });
243
+ break;
244
+ case "%":
245
+ tokens.push({ kind: "PERCENT", value: "%" });
246
+ break;
247
+ case "^":
248
+ tokens.push({ kind: "CARET", value: "^" });
249
+ break;
250
+ case "(":
251
+ tokens.push({ kind: "LPAREN", value: "(" });
252
+ break;
253
+ case ")":
254
+ tokens.push({ kind: "RPAREN", value: ")" });
255
+ break;
256
+ case ",":
257
+ tokens.push({ kind: "COMMA", value: "," });
258
+ break;
259
+ case "<":
260
+ tokens.push({ kind: "LT", value: "<" });
261
+ break;
262
+ case ">":
263
+ tokens.push({ kind: "GT", value: ">" });
264
+ break;
265
+ case "=":
266
+ tokens.push({ kind: "EQ", value: "=" });
267
+ break;
268
+ default:
269
+ // Unknown character is an error
270
+ throw new Error(`Unknown character: ${ch}`);
271
+ }
272
+ i++;
273
+ }
274
+
275
+ tokens.push({ kind: "EOF", value: "" });
276
+ return tokens;
277
+ }
278
+
279
+ /**
280
+ * Recursive descent parser for expressions
281
+ * Precedence (low to high):
282
+ * or
283
+ * and
284
+ * not (unary)
285
+ * comparison (<, >, <=, >=, =, !=)
286
+ * addition (+, -)
287
+ * multiplication (*, /, %)
288
+ * power (^)
289
+ * unary (-, +)
290
+ * primary (number, parentheses, function call)
291
+ */
292
+ class ExprParser {
293
+ private pos = 0;
294
+
295
+ constructor(private tokens: ExprToken[]) {}
296
+
297
+ parse(): number {
298
+ const result = this.parseOr();
299
+ if (this.current().kind !== "EOF") {
300
+ // Wikidot-compatible error message when extra values remain
301
+ throw new Error("too many values in the stack");
302
+ }
303
+ return result;
304
+ }
305
+
306
+ private current(): ExprToken {
307
+ return this.tokens[this.pos] ?? { kind: "EOF", value: "" };
308
+ }
309
+
310
+ private advance(): ExprToken {
311
+ const token = this.current();
312
+ this.pos++;
313
+ return token;
314
+ }
315
+
316
+ private parseOr(): number {
317
+ let left = this.parseAnd();
318
+
319
+ while (this.current().kind === "IDENTIFIER" && this.current().value === "or") {
320
+ this.advance();
321
+ const right = this.parseAnd();
322
+ // Treat 0 and NaN as falsy
323
+ left = isTruthyNum(left) || isTruthyNum(right) ? 1 : 0;
324
+ }
325
+
326
+ return left;
327
+ }
328
+
329
+ private parseAnd(): number {
330
+ let left = this.parseNot();
331
+
332
+ while (this.current().kind === "IDENTIFIER" && this.current().value === "and") {
333
+ this.advance();
334
+ const right = this.parseNot();
335
+ // Treat 0 and NaN as falsy
336
+ left = isTruthyNum(left) && isTruthyNum(right) ? 1 : 0;
337
+ }
338
+
339
+ return left;
340
+ }
341
+
342
+ private parseNot(): number {
343
+ const cur = this.current();
344
+ if ((cur.kind === "IDENTIFIER" && cur.value === "not") || cur.kind === "BANG") {
345
+ this.advance();
346
+ const value = this.parseNot();
347
+ // Treat 0 and NaN as falsy
348
+ return isTruthyNum(value) ? 0 : 1;
349
+ }
350
+ return this.parseComparison();
351
+ }
352
+
353
+ private parseComparison(): number {
354
+ let left = this.parseAddition();
355
+
356
+ const kind = this.current().kind;
357
+ if (
358
+ kind === "LT" ||
359
+ kind === "GT" ||
360
+ kind === "LE" ||
361
+ kind === "GE" ||
362
+ kind === "EQ" ||
363
+ kind === "NE"
364
+ ) {
365
+ this.advance();
366
+ const right = this.parseAddition();
367
+
368
+ switch (kind) {
369
+ case "LT":
370
+ return left < right ? 1 : 0;
371
+ case "GT":
372
+ return left > right ? 1 : 0;
373
+ case "LE":
374
+ return left <= right ? 1 : 0;
375
+ case "GE":
376
+ return left >= right ? 1 : 0;
377
+ case "EQ":
378
+ return left === right ? 1 : 0;
379
+ case "NE":
380
+ return left !== right ? 1 : 0;
381
+ }
382
+ }
383
+
384
+ return left;
385
+ }
386
+
387
+ private parseAddition(): number {
388
+ let left = this.parseMultiplication();
389
+
390
+ while (true) {
391
+ const kind = this.current().kind;
392
+ if (kind === "PLUS") {
393
+ this.advance();
394
+ left = left + this.parseMultiplication();
395
+ } else if (kind === "MINUS") {
396
+ this.advance();
397
+ left = left - this.parseMultiplication();
398
+ } else {
399
+ break;
400
+ }
401
+ }
402
+
403
+ return left;
404
+ }
405
+
406
+ private parseMultiplication(): number {
407
+ let left = this.parsePower();
408
+
409
+ while (true) {
410
+ const kind = this.current().kind;
411
+ if (kind === "STAR") {
412
+ this.advance();
413
+ left = left * this.parsePower();
414
+ } else if (kind === "SLASH") {
415
+ this.advance();
416
+ left = left / this.parsePower();
417
+ } else if (kind === "PERCENT") {
418
+ this.advance();
419
+ left = left % this.parsePower();
420
+ } else {
421
+ break;
422
+ }
423
+ }
424
+
425
+ return left;
426
+ }
427
+
428
+ private parsePower(): number {
429
+ const left = this.parseUnary();
430
+
431
+ if (this.current().kind === "CARET") {
432
+ this.advance();
433
+ // Right-associative
434
+ const right = this.parsePower();
435
+ return Math.pow(left, right);
436
+ }
437
+
438
+ return left;
439
+ }
440
+
441
+ private parseUnary(): number {
442
+ const kind = this.current().kind;
443
+
444
+ if (kind === "MINUS") {
445
+ this.advance();
446
+ return -this.parseUnary();
447
+ }
448
+ if (kind === "PLUS") {
449
+ this.advance();
450
+ return +this.parseUnary();
451
+ }
452
+ // `!` here applies when the parser descends past the top-level
453
+ // `parseNot` (e.g. on the RHS of a comparison: `a != !(b)`).
454
+ if (kind === "BANG") {
455
+ this.advance();
456
+ const value = this.parseUnary();
457
+ return isTruthyNum(value) ? 0 : 1;
458
+ }
459
+
460
+ return this.parsePrimary();
461
+ }
462
+
463
+ private parsePrimary(): number {
464
+ const token = this.current();
465
+
466
+ if (token.kind === "NUMBER") {
467
+ this.advance();
468
+ return token.value as number;
469
+ }
470
+
471
+ if (token.kind === "LPAREN") {
472
+ this.advance();
473
+ const value = this.parseOr();
474
+ if (this.current().kind !== "RPAREN") {
475
+ throw new Error("Expected )");
476
+ }
477
+ this.advance();
478
+ return value;
479
+ }
480
+
481
+ if (token.kind === "IDENTIFIER") {
482
+ const name = token.value as string;
483
+ this.advance();
484
+
485
+ // Function call
486
+ if (this.current().kind === "LPAREN") {
487
+ return this.parseFunctionCall(name);
488
+ }
489
+
490
+ // Wikidot accepts `true` / `false` as boolean literals inside
491
+ // `[[#expr]]` / `[[#ifexpr]]` (lowercased above by the tokenizer).
492
+ if (name === "true") return 1;
493
+ if (name === "false") return 0;
494
+
495
+ throw new Error(`undefined constant "${name}"`);
496
+ }
497
+
498
+ throw new Error("Expected expression");
499
+ }
500
+
501
+ private parseFunctionCall(name: string): number {
502
+ if (this.current().kind !== "LPAREN") {
503
+ throw new Error("Expected (");
504
+ }
505
+ this.advance();
506
+
507
+ const args: number[] = [];
508
+
509
+ if (this.current().kind !== "RPAREN") {
510
+ args.push(this.parseOr());
511
+
512
+ while (this.current().kind === "COMMA") {
513
+ this.advance();
514
+ args.push(this.parseOr());
515
+ }
516
+ }
517
+
518
+ if (this.current().kind !== "RPAREN") {
519
+ throw new Error("Expected )");
520
+ }
521
+ this.advance();
522
+
523
+ return this.callFunction(name, args);
524
+ }
525
+
526
+ private callFunction(name: string, args: number[]): number {
527
+ switch (name) {
528
+ case "abs":
529
+ this.checkArgs(name, args, 1);
530
+ return Math.abs(args[0]!);
531
+ case "min":
532
+ this.checkArgsMin(name, args, 1);
533
+ return Math.min(...args);
534
+ case "max":
535
+ this.checkArgsMin(name, args, 1);
536
+ return Math.max(...args);
537
+ case "floor":
538
+ this.checkArgs(name, args, 1);
539
+ return Math.floor(args[0]!);
540
+ case "ceil":
541
+ this.checkArgs(name, args, 1);
542
+ return Math.ceil(args[0]!);
543
+ case "round":
544
+ this.checkArgs(name, args, 1);
545
+ return Math.round(args[0]!);
546
+ case "sqrt":
547
+ this.checkArgs(name, args, 1);
548
+ return Math.sqrt(args[0]!);
549
+ case "sin":
550
+ this.checkArgs(name, args, 1);
551
+ return Math.sin(args[0]!);
552
+ case "cos":
553
+ this.checkArgs(name, args, 1);
554
+ return Math.cos(args[0]!);
555
+ case "tan":
556
+ this.checkArgs(name, args, 1);
557
+ return Math.tan(args[0]!);
558
+ case "ln":
559
+ this.checkArgs(name, args, 1);
560
+ return Math.log(args[0]!);
561
+ case "log":
562
+ this.checkArgs(name, args, 1);
563
+ return Math.log10(args[0]!);
564
+ case "exp":
565
+ this.checkArgs(name, args, 1);
566
+ return Math.exp(args[0]!);
567
+ case "pow":
568
+ this.checkArgs(name, args, 2);
569
+ return Math.pow(args[0]!, args[1]!);
570
+ default:
571
+ throw new Error(`undefined function "${name}"`);
572
+ }
573
+ }
574
+
575
+ private checkArgs(name: string, args: number[], expected: number): void {
576
+ if (args.length !== expected) {
577
+ throw new Error(`${name}() expects ${expected} argument(s), got ${args.length}`);
578
+ }
579
+ }
580
+
581
+ private checkArgsMin(name: string, args: number[], min: number): void {
582
+ if (args.length < min) {
583
+ throw new Error(`${name}() expects at least ${min} argument(s), got ${args.length}`);
584
+ }
585
+ }
586
+ }
package/src/index.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * AST type definitions for the Wikidot markup parser.
3
+ *
4
+ * This package provides the TypeScript types that describe the abstract
5
+ * syntax tree (AST) produced by `@wdprlib/parser` and consumed by
6
+ * `@wdprlib/render`. It also exports factory helpers for constructing
7
+ * common node types and context-dependent settings for controlling
8
+ * parser/renderer behaviour.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ export type { Position, Point } from "./position";
14
+ export { createPoint, createPosition } from "./position";
15
+
16
+ /**
17
+ * Identifies the source markup dialect.
18
+ *
19
+ * Currently only `"wikidot"` is supported. Included in {@link SyntaxTree}
20
+ * so consumers can branch on the dialect if other formats are added later.
21
+ *
22
+ * @group Core
23
+ */
24
+ export type Version = "wikidot";
25
+
26
+ // Element types (output AST)
27
+ export type {
28
+ Element,
29
+ ElementName,
30
+ ElementData,
31
+ ElementOf,
32
+ ElementDataMap,
33
+ SyntaxTree,
34
+ ContainerType,
35
+ StringContainerType,
36
+ ContainerData,
37
+ AttributeMap,
38
+ VariableMap,
39
+ Alignment,
40
+ LinkType,
41
+ LinkLocation,
42
+ LinkLabel,
43
+ PageRef,
44
+ ImageSource,
45
+ FloatAlignment,
46
+ ListType,
47
+ ListItem,
48
+ ListData,
49
+ CodeBlockData,
50
+ TabData,
51
+ TableCell,
52
+ TableRow,
53
+ TableData,
54
+ DefinitionListItem,
55
+ Module,
56
+ CollapsibleData,
57
+ ClearFloat,
58
+ AnchorTarget,
59
+ HeaderType,
60
+ AlignType,
61
+ HeadingLevel,
62
+ Heading,
63
+ DateItem,
64
+ Embed,
65
+ EmbedBlockData,
66
+ TocEntry,
67
+ AnchorData,
68
+ LinkData,
69
+ ImageData,
70
+ TableOfContentsData,
71
+ FootnoteBlockData,
72
+ BibliographyCiteData,
73
+ BibliographyBlockData,
74
+ UserData,
75
+ DateData,
76
+ ColorData,
77
+ MathData,
78
+ MathInlineData,
79
+ HtmlData,
80
+ IframeData,
81
+ IncludeData,
82
+ IfTagsData,
83
+ ExprData,
84
+ IfCondData,
85
+ IfExprData,
86
+ } from "./element";
87
+ export {
88
+ text,
89
+ container,
90
+ paragraph,
91
+ bold,
92
+ italics,
93
+ heading,
94
+ lineBreak,
95
+ horizontalRule,
96
+ link,
97
+ list,
98
+ listItemElements,
99
+ listItemSubList,
100
+ isStringContainerType,
101
+ isHeaderType,
102
+ isAlignType,
103
+ isContainerTypeParagraphSafe,
104
+ isParagraphSafe,
105
+ } from "./element";
106
+
107
+ // Diagnostics
108
+ export type { Diagnostic, DiagnosticSeverity, ParseResult } from "./diagnostic";
109
+
110
+ // Constants
111
+ export { STYLE_SLOT_PREFIX } from "./constants";
112
+
113
+ // Wikitext settings
114
+ export type { WikitextMode, WikitextSettings } from "./settings";
115
+
116
+ // Expression evaluator (shared by parser preprocess and render).
117
+ export { evaluateExpression, isTruthy, formatExprValue } from "./expr-eval";
118
+ export type { ExprResult } from "./expr-eval";
119
+ export { createSettings, DEFAULT_SETTINGS } from "./settings";