rip-lang 3.13.133 → 3.13.135
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/CHANGELOG.md +0 -1
- package/README.md +4 -7
- package/bin/rip +16 -4
- package/docs/RIP-LANG.md +0 -42
- package/docs/RIP-TYPES.md +47 -52
- package/docs/demo.html +2 -2
- package/docs/dist/rip.js +2294 -1544
- package/docs/dist/rip.min.js +202 -192
- package/docs/dist/rip.min.js.br +0 -0
- package/package.json +1 -1
- package/rip-loader.js +2 -2
- package/src/AGENTS.md +76 -11
- package/src/browser.js +5 -5
- package/src/compiler.js +961 -639
- package/src/components.js +274 -109
- package/src/error.js +250 -0
- package/src/grammar/grammar.rip +2 -12
- package/src/lexer.js +15 -11
- package/src/parser.js +220 -223
- package/src/repl.js +3 -2
- package/src/sourcemap-utils.js +39 -6
- package/src/typecheck.js +312 -80
- package/src/types.js +229 -54
- package/src/ui.rip +4 -0
package/src/error.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// RipError — structured diagnostics for the Rip compiler
|
|
2
|
+
//
|
|
3
|
+
// Unifies error reporting across lexer, parser, and codegen with source
|
|
4
|
+
// locations, contextual snippets, and carets. Consumers (CLI, loader, browser,
|
|
5
|
+
// REPL, server) call format() for terminal output or formatHTML() for browser.
|
|
6
|
+
|
|
7
|
+
export class RipError extends Error {
|
|
8
|
+
constructor(message, {
|
|
9
|
+
code = null, // e.g. 'E_SYNTAX', 'E_CODEGEN', 'E_PARSE'
|
|
10
|
+
file = null, // source filename
|
|
11
|
+
line = null, // 0-based line number
|
|
12
|
+
column = null, // 0-based column number
|
|
13
|
+
length = 1, // length of the offending span
|
|
14
|
+
source = null, // full original source text
|
|
15
|
+
suggestion = null,
|
|
16
|
+
phase = null, // 'lexer', 'parser', 'codegen'
|
|
17
|
+
} = {}) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'RipError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.file = file;
|
|
22
|
+
this.line = line;
|
|
23
|
+
this.column = column;
|
|
24
|
+
this.length = length;
|
|
25
|
+
this.source = source;
|
|
26
|
+
this.suggestion = suggestion;
|
|
27
|
+
this.phase = phase;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Construct from a lexer SyntaxError (has .location)
|
|
31
|
+
static fromLexer(err, source, file) {
|
|
32
|
+
let loc = err.location || {};
|
|
33
|
+
return new RipError(err.message, {
|
|
34
|
+
code: 'E_SYNTAX',
|
|
35
|
+
file,
|
|
36
|
+
line: loc.first_line ?? null,
|
|
37
|
+
column: loc.first_column ?? null,
|
|
38
|
+
length: loc.last_column != null && loc.first_column != null
|
|
39
|
+
? loc.last_column - loc.first_column + 1 : 1,
|
|
40
|
+
source,
|
|
41
|
+
phase: 'lexer',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Construct from a parser Error (has .hash with line, loc, token, expected)
|
|
46
|
+
static fromParser(err, source, file) {
|
|
47
|
+
let h = err.hash || {};
|
|
48
|
+
let loc = h.loc || {};
|
|
49
|
+
let line = h.line ?? loc.r ?? null;
|
|
50
|
+
let column = loc.first_column ?? loc.c ?? null;
|
|
51
|
+
let suggestion = null;
|
|
52
|
+
if (h.expected?.length) {
|
|
53
|
+
let first5 = h.expected.slice(0, 5).map(e => e.replace(/'/g, ''));
|
|
54
|
+
suggestion = `Expected ${first5.join(', ')}`;
|
|
55
|
+
if (h.expected.length > 5) suggestion += `, ... (${h.expected.length} total)`;
|
|
56
|
+
}
|
|
57
|
+
// Build a clean message from the hash instead of using the parser's pre-formatted string
|
|
58
|
+
let token = h.token || 'token';
|
|
59
|
+
let near = h.text ? ` near '${h.text}'` : '';
|
|
60
|
+
let message = `Unexpected ${token}${near}`;
|
|
61
|
+
return new RipError(message, {
|
|
62
|
+
code: 'E_PARSE',
|
|
63
|
+
file,
|
|
64
|
+
line,
|
|
65
|
+
column,
|
|
66
|
+
length: h.text?.length || 1,
|
|
67
|
+
source,
|
|
68
|
+
suggestion,
|
|
69
|
+
phase: 'parser',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Construct from an s-expression node's .loc in the codegen phase
|
|
74
|
+
static fromSExpr(message, sexpr, source, file, suggestion) {
|
|
75
|
+
let loc = sexpr?.loc || {};
|
|
76
|
+
return new RipError(message, {
|
|
77
|
+
code: 'E_CODEGEN',
|
|
78
|
+
file,
|
|
79
|
+
line: loc.r ?? null,
|
|
80
|
+
column: loc.c ?? null,
|
|
81
|
+
length: loc.n ?? 1,
|
|
82
|
+
source,
|
|
83
|
+
suggestion,
|
|
84
|
+
phase: 'codegen',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Human-readable location string: "file.rip:3:5" or "3:5" or ""
|
|
89
|
+
get locationString() {
|
|
90
|
+
let parts = [];
|
|
91
|
+
if (this.file) parts.push(this.file);
|
|
92
|
+
if (this.line != null) {
|
|
93
|
+
parts.push(`${this.line + 1}:${(this.column ?? 0) + 1}`);
|
|
94
|
+
}
|
|
95
|
+
return parts.join(':');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- Terminal formatter ----
|
|
99
|
+
|
|
100
|
+
format({ color = true } = {}) {
|
|
101
|
+
let c = color ? {
|
|
102
|
+
red: '\x1b[31m',
|
|
103
|
+
yellow: '\x1b[33m',
|
|
104
|
+
cyan: '\x1b[36m',
|
|
105
|
+
dim: '\x1b[2m',
|
|
106
|
+
bold: '\x1b[1m',
|
|
107
|
+
reset: '\x1b[0m',
|
|
108
|
+
} : { red: '', yellow: '', cyan: '', dim: '', bold: '', reset: '' };
|
|
109
|
+
|
|
110
|
+
let lines = [];
|
|
111
|
+
|
|
112
|
+
// Header: error message
|
|
113
|
+
let loc = this.locationString;
|
|
114
|
+
let header = loc ? `${c.cyan}${loc}${c.reset} ` : '';
|
|
115
|
+
lines.push(`${header}${c.red}${c.bold}error${c.reset}${c.bold}: ${this.message}${c.reset}`);
|
|
116
|
+
|
|
117
|
+
// Source snippet with caret
|
|
118
|
+
let snippet = this._snippet();
|
|
119
|
+
if (snippet) {
|
|
120
|
+
lines.push('');
|
|
121
|
+
for (let s of snippet) {
|
|
122
|
+
if (s.type === 'source') {
|
|
123
|
+
lines.push(`${c.dim}${s.gutter}${c.reset}${s.text}`);
|
|
124
|
+
} else if (s.type === 'caret') {
|
|
125
|
+
lines.push(`${c.dim}${s.gutter}${c.reset}${c.red}${c.bold}${s.text}${c.reset}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Suggestion
|
|
131
|
+
if (this.suggestion) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push(`${c.yellow}hint${c.reset}: ${this.suggestion}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- HTML formatter ----
|
|
140
|
+
|
|
141
|
+
formatHTML() {
|
|
142
|
+
let lines = [];
|
|
143
|
+
lines.push('<div class="rip-error">');
|
|
144
|
+
lines.push('<style>');
|
|
145
|
+
lines.push(`.rip-error { font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; font-size: 13px; line-height: 1.5; padding: 16px 20px; background: #1e1e2e; color: #cdd6f4; border-radius: 8px; overflow-x: auto; }`);
|
|
146
|
+
lines.push(`.rip-error .re-header { color: #f38ba8; font-weight: 600; }`);
|
|
147
|
+
lines.push(`.rip-error .re-loc { color: #89b4fa; }`);
|
|
148
|
+
lines.push(`.rip-error .re-gutter { color: #585b70; user-select: none; }`);
|
|
149
|
+
lines.push(`.rip-error .re-caret { color: #f38ba8; font-weight: 700; }`);
|
|
150
|
+
lines.push(`.rip-error .re-hint { color: #f9e2af; }`);
|
|
151
|
+
lines.push(`.rip-error .re-snippet { margin: 8px 0; }`);
|
|
152
|
+
lines.push('</style>');
|
|
153
|
+
|
|
154
|
+
let loc = this.locationString;
|
|
155
|
+
let locSpan = loc ? `<span class="re-loc">${esc(loc)}</span> ` : '';
|
|
156
|
+
lines.push(`<div class="re-header">${locSpan}error: ${esc(this.message)}</div>`);
|
|
157
|
+
|
|
158
|
+
let snippet = this._snippet();
|
|
159
|
+
if (snippet) {
|
|
160
|
+
lines.push('<pre class="re-snippet">');
|
|
161
|
+
for (let s of snippet) {
|
|
162
|
+
if (s.type === 'source') {
|
|
163
|
+
lines.push(`<span class="re-gutter">${esc(s.gutter)}</span>${esc(s.text)}`);
|
|
164
|
+
} else if (s.type === 'caret') {
|
|
165
|
+
lines.push(`<span class="re-gutter">${esc(s.gutter)}</span><span class="re-caret">${esc(s.text)}</span>`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lines.push('</pre>');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.suggestion) {
|
|
172
|
+
lines.push(`<div class="re-hint">hint: ${esc(this.suggestion)}</div>`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push('</div>');
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- Snippet builder (shared by format and formatHTML) ----
|
|
180
|
+
|
|
181
|
+
_snippet() {
|
|
182
|
+
if (this.source == null || this.line == null) return null;
|
|
183
|
+
|
|
184
|
+
let sourceLines = this.source.split('\n');
|
|
185
|
+
let errLine = this.line;
|
|
186
|
+
if (errLine < 0 || errLine >= sourceLines.length) return null;
|
|
187
|
+
|
|
188
|
+
let contextRadius = 2;
|
|
189
|
+
let start = Math.max(0, errLine - contextRadius);
|
|
190
|
+
let end = Math.min(sourceLines.length - 1, errLine + contextRadius);
|
|
191
|
+
let gutterWidth = String(end + 1).length;
|
|
192
|
+
|
|
193
|
+
let result = [];
|
|
194
|
+
|
|
195
|
+
for (let i = start; i <= end; i++) {
|
|
196
|
+
let lineNum = String(i + 1).padStart(gutterWidth);
|
|
197
|
+
let gutter = ` ${lineNum} │ `;
|
|
198
|
+
result.push({ type: 'source', gutter, text: sourceLines[i] });
|
|
199
|
+
|
|
200
|
+
if (i === errLine && this.column != null) {
|
|
201
|
+
let pad = ' '.repeat(this.column);
|
|
202
|
+
let caretLen = Math.max(1, Math.min(this.length || 1, sourceLines[i].length - this.column));
|
|
203
|
+
let carets = '^'.repeat(caretLen);
|
|
204
|
+
let emptyGutter = ' '.repeat(gutterWidth + 2) + '│ ';
|
|
205
|
+
result.push({ type: 'caret', gutter: emptyGutter, text: `${pad}${carets}` });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Detect whether an error is a lexer SyntaxError with .location
|
|
214
|
+
export function isLexerError(err) {
|
|
215
|
+
return err instanceof SyntaxError && err.location != null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Detect whether an error is a parser error with .hash
|
|
219
|
+
export function isParserError(err) {
|
|
220
|
+
return !(err instanceof SyntaxError) && err.hash != null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Upgrade any error to RipError (idempotent on RipError instances)
|
|
224
|
+
export function toRipError(err, source, file) {
|
|
225
|
+
if (err instanceof RipError) {
|
|
226
|
+
if (file && !err.file) err.file = file;
|
|
227
|
+
if (source && !err.source) err.source = source;
|
|
228
|
+
return err;
|
|
229
|
+
}
|
|
230
|
+
if (isLexerError(err)) return RipError.fromLexer(err, source, file);
|
|
231
|
+
if (isParserError(err)) return RipError.fromParser(err, source, file);
|
|
232
|
+
// Unknown error — wrap with no location
|
|
233
|
+
return new RipError(err.message, { file, source, phase: 'unknown' });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Format any error for terminal display (works on RipError and plain Error)
|
|
237
|
+
export function formatError(err, { source, file, color = true } = {}) {
|
|
238
|
+
let re = (err instanceof RipError) ? err : toRipError(err, source, file);
|
|
239
|
+
return re.format({ color });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Format any error for HTML display
|
|
243
|
+
export function formatErrorHTML(err, { source, file } = {}) {
|
|
244
|
+
let re = (err instanceof RipError) ? err : toRipError(err, source, file);
|
|
245
|
+
return re.formatHTML();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function esc(s) {
|
|
249
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
250
|
+
}
|
package/src/grammar/grammar.rip
CHANGED
|
@@ -70,7 +70,6 @@ grammar =
|
|
|
70
70
|
o 'Code'
|
|
71
71
|
o 'Operation'
|
|
72
72
|
o 'Assign'
|
|
73
|
-
o 'RightwardAssign'
|
|
74
73
|
o 'ReactiveAssign'
|
|
75
74
|
o 'ComputedAssign'
|
|
76
75
|
o 'ReadonlyAssign'
|
|
@@ -188,11 +187,6 @@ grammar =
|
|
|
188
187
|
o 'Assignable = INDENT Expression OUTDENT', '["=", 1, 4]'
|
|
189
188
|
]
|
|
190
189
|
|
|
191
|
-
# Rightward assignment (:>) — expression first, target second
|
|
192
|
-
RightwardAssign: [
|
|
193
|
-
o 'Expression RIGHTWARD_ASSIGN Assignable', '["=", 3, 1]'
|
|
194
|
-
]
|
|
195
|
-
|
|
196
190
|
# Reactive state (:=) — mutable reactive values
|
|
197
191
|
ReactiveAssign: [
|
|
198
192
|
o 'Assignable REACTIVE_ASSIGN Expression' , '["state", 1, 3]'
|
|
@@ -899,9 +893,6 @@ grammar =
|
|
|
899
893
|
# Postfix existence check: expr? → (expr != null)
|
|
900
894
|
o 'Value ?' , '["?", 1]'
|
|
901
895
|
|
|
902
|
-
# Postfix defined check: expr!? → (expr !== undefined)
|
|
903
|
-
o 'Value DEFINED' , '["defined", 1]'
|
|
904
|
-
|
|
905
896
|
# Postfix presence check: expr?! → (expr ? true : undefined) — Houdini operator
|
|
906
897
|
o 'Value PRESENCE' , '["presence", 1]'
|
|
907
898
|
|
|
@@ -938,7 +929,6 @@ grammar =
|
|
|
938
929
|
o 'Expression && Expression' , '["&&", 1, 3]'
|
|
939
930
|
o 'Expression || Expression' , '["||", 1, 3]'
|
|
940
931
|
o 'Expression ?? Expression' , '["??", 1, 3]'
|
|
941
|
-
o 'Expression !? Expression' , '["!?", 1, 3]' # Otherwise (undefined-only coalescing)
|
|
942
932
|
|
|
943
933
|
# Pipe
|
|
944
934
|
o 'Expression PIPE Expression' , '["|>", 1, 3]'
|
|
@@ -968,7 +958,7 @@ operators = """
|
|
|
968
958
|
right DO_IIFE
|
|
969
959
|
left . ?.
|
|
970
960
|
left CALL_START CALL_END
|
|
971
|
-
nonassoc ++ -- ?
|
|
961
|
+
nonassoc ++ -- ? PRESENCE
|
|
972
962
|
right UNARY DO
|
|
973
963
|
right AWAIT
|
|
974
964
|
right **
|
|
@@ -987,7 +977,7 @@ operators = """
|
|
|
987
977
|
right TERNARY
|
|
988
978
|
nonassoc INDENT OUTDENT
|
|
989
979
|
right YIELD
|
|
990
|
-
right = : COMPOUND_ASSIGN
|
|
980
|
+
right = : COMPOUND_ASSIGN RETURN THROW EXTENDS
|
|
991
981
|
right FORIN FOROF FORAS FORASAWAIT BY WHEN
|
|
992
982
|
right IF ELSE FOR WHILE UNTIL LOOP SUPER CLASS COMPONENT RENDER IMPORT EXPORT DYNAMIC_IMPORT OFFER ACCEPT
|
|
993
983
|
left POST_IF POST_UNLESS
|
package/src/lexer.js
CHANGED
|
@@ -215,10 +215,9 @@ let UNARY_MATH = new Set(['!', '~']);
|
|
|
215
215
|
// Identifier: word chars + optional trailing ! (await) or ? (predicate)
|
|
216
216
|
// The ? suffix is only captured when NOT followed by . ? ! [ ( to avoid
|
|
217
217
|
// conflict with ?. (optional chaining), ?? (nullish), ?! (presence), ?.( and ?.[
|
|
218
|
-
|
|
219
|
-
let IDENTIFIER_RE = /^(?!\d)((?:(?!\s)[$\w\x7f-\uffff])+(?:!(?!\?)|[?](?![.?![(]))?)([^\n\S]*:(?![=:>]))?/;
|
|
218
|
+
let IDENTIFIER_RE = /^(?!\d)((?:(?!\s)[$\w\x7f-\uffff])+(?:!|[?](?![.?![(]))?)([^\n\S]*:(?![=:]))?/;
|
|
220
219
|
let NUMBER_RE = /^0b[01](?:_?[01])*n?|^0o[0-7](?:_?[0-7])*n?|^0x[\da-f](?:_?[\da-f])*n?|^\d+(?:_\d+)*n|^(?:\d+(?:_\d+)*)?\.?\d+(?:_\d+)*(?:e[+-]?\d+(?:_\d+)*)?/i;
|
|
221
|
-
let OPERATOR_RE = /^(?:<=>|::|\*>|[-=]
|
|
220
|
+
let OPERATOR_RE = /^(?:<=>|::|\*>|[-=]>|~>|~=|:=|=!|===|!==|\?\!|\?\?|=~|\|>|[-+*\/%<>&|^!?=]=|>>>=?|([-+:])\1|([&|<>*\/%])\2=?|\?\.?|\.{2,3})/;
|
|
222
221
|
let WHITESPACE_RE = /^[^\n\S]+/;
|
|
223
222
|
let NEWLINE_RE = /^(?:\n[^\n\S]*)+/;
|
|
224
223
|
let COMMENT_RE = /^(\s*)###([^#][\s\S]*?)(?:###([^\n\S]*)|###$)|^((?:\s*#(?!##[^#]).*)+)/;
|
|
@@ -543,9 +542,12 @@ export class Lexer {
|
|
|
543
542
|
}
|
|
544
543
|
|
|
545
544
|
// Reserved words (check the base form, not the suffixed form)
|
|
546
|
-
if (tag === 'IDENTIFIER' && RESERVED.has(baseId)
|
|
547
|
-
|
|
548
|
-
|
|
545
|
+
if (tag === 'IDENTIFIER' && RESERVED.has(baseId)) {
|
|
546
|
+
if (baseId === 'void' && (this.inTypeAnnotation || this.prevTag() === '=>')) {
|
|
547
|
+
// ok — void used as a type (after :: or =>)
|
|
548
|
+
} else {
|
|
549
|
+
syntaxError(`reserved word '${baseId}'`, {row: this.row, col: this.col, len: idLen});
|
|
550
|
+
}
|
|
549
551
|
}
|
|
550
552
|
|
|
551
553
|
// Property-specific checks (new.target, import.meta)
|
|
@@ -779,15 +781,20 @@ export class Lexer {
|
|
|
779
781
|
}
|
|
780
782
|
}
|
|
781
783
|
}
|
|
782
|
-
// A > that closes a generic type annotation is NOT a continuation
|
|
784
|
+
// A > or >> that closes a generic type annotation/alias is NOT a continuation
|
|
783
785
|
let prev = this.tokens[this.tokens.length - 1];
|
|
784
|
-
|
|
786
|
+
let isGenericClose = (prev?.[0] === 'COMPARE' && prev[1] === '>') ||
|
|
787
|
+
(prev?.[0] === 'SHIFT' && (prev[1] === '>>' || prev[1] === '>>>'));
|
|
788
|
+
if (isGenericClose) {
|
|
785
789
|
let depth = 0;
|
|
786
790
|
for (let k = this.tokens.length - 1; k >= 0; k--) {
|
|
787
791
|
let tk = this.tokens[k];
|
|
788
792
|
if (tk[0] === 'COMPARE' && tk[1] === '>') depth++;
|
|
793
|
+
else if (tk[0] === 'SHIFT' && tk[1] === '>>') depth += 2;
|
|
794
|
+
else if (tk[0] === 'SHIFT' && tk[1] === '>>>') depth += 3;
|
|
789
795
|
else if (tk[0] === 'COMPARE' && tk[1] === '<') depth--;
|
|
790
796
|
if (depth === 0 && tk[0] === 'TYPE_ANNOTATION') return false;
|
|
797
|
+
if (depth === 0 && tk[0] === 'IDENTIFIER' && tk[1] === 'type') return false;
|
|
791
798
|
if (tk[0] === 'TERMINATOR' || tk[0] === 'INDENT' || tk[0] === 'OUTDENT') break;
|
|
792
799
|
}
|
|
793
800
|
}
|
|
@@ -1230,7 +1237,6 @@ export class Lexer {
|
|
|
1230
1237
|
// Reactive and binding operators
|
|
1231
1238
|
else if (val === '~=') tag = 'COMPUTED_ASSIGN';
|
|
1232
1239
|
else if (val === ':=') tag = 'REACTIVE_ASSIGN';
|
|
1233
|
-
else if (val === ':>') tag = 'RIGHTWARD_ASSIGN';
|
|
1234
1240
|
else if (val === '<=>') tag = 'BIND';
|
|
1235
1241
|
else if (val === '~>') { tag = 'EFFECT'; this.inTypeAnnotation = false; }
|
|
1236
1242
|
else if (val === '=!') { tag = 'READONLY_ASSIGN'; this.inTypeAnnotation = false; }
|
|
@@ -1297,8 +1303,6 @@ export class Lexer {
|
|
|
1297
1303
|
else if (SHIFT.has(val)) tag = 'SHIFT';
|
|
1298
1304
|
// Spaced ? → TERNARY (ternary)
|
|
1299
1305
|
else if (val === '?' && prev?.spaced) tag = 'TERNARY';
|
|
1300
|
-
// Unspaced !? → DEFINED (postfix defined check: v!? → v !== undefined)
|
|
1301
|
-
else if (val === '!?' && prev && !prev.spaced) tag = 'DEFINED';
|
|
1302
1306
|
// Unspaced ?! → PRESENCE (Houdini: v?! → v ? true : undefined)
|
|
1303
1307
|
else if (val === '?!' && prev && !prev.spaced) tag = 'PRESENCE';
|
|
1304
1308
|
// ?[ and ?( without dot → treat as optional chaining (?.)
|