@formaui-net/nexo 0.0.0 → 1.0.0-preview.1
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 +202 -0
- package/NOTICE +2 -0
- package/README.md +148 -6
- package/package.json +23 -3
- package/src/common/ArrayUtils.mjs +16 -0
- package/src/common/ObjectUtils.mjs +33 -0
- package/src/language/DefinitionLocations.mjs +41 -0
- package/src/language/DefinitionValidator.mjs +1265 -0
- package/src/language/DefinitionWriter.mjs +475 -0
- package/src/language/Dictionary.mjs +52 -0
- package/src/language/Evaluator.mjs +3480 -0
- package/src/language/ImportedVocabulary.mjs +137 -0
- package/src/language/Imports.mjs +141 -0
- package/src/language/Index.mjs +29 -0
- package/src/language/LanguageResolver.mjs +4068 -0
- package/src/language/Limits.mjs +78 -0
- package/src/language/LineStructure.mjs +385 -0
- package/src/language/Lowering.mjs +453 -0
- package/src/language/MemberSelector.mjs +89 -0
- package/src/language/Names.mjs +65 -0
- package/src/language/NexoError.mjs +145 -0
- package/src/language/Pair.mjs +21 -0
- package/src/language/ParameterBindings.mjs +361 -0
- package/src/language/ParsedTexts.mjs +62 -0
- package/src/language/Parser.mjs +1817 -0
- package/src/language/Range.mjs +51 -0
- package/src/language/RecentlyUsed.mjs +50 -0
- package/src/language/ResolvedDefinition.mjs +219 -0
- package/src/language/ResultPath.mjs +101 -0
- package/src/language/ScopeLocals.mjs +102 -0
- package/src/language/StructureTypes.mjs +48 -0
- package/src/language/SyntaxTree.mjs +203 -0
- package/src/language/TextLocations.mjs +185 -0
- package/src/language/TextPositions.mjs +23 -0
- package/src/language/Tokenizer.mjs +917 -0
- package/src/language/Type.mjs +401 -0
- package/src/language/TypeChecker.mjs +449 -0
- package/src/language/ValueCapture.mjs +315 -0
- package/src/language/Versions.mjs +14 -0
- package/src/language/vocabulary/Declarations.mjs +64 -0
- package/src/language/vocabulary/ItemPositions.mjs +48 -0
- package/src/language/vocabulary/ListOrdering.mjs +145 -0
- package/src/language/vocabulary/MemberFailure.mjs +21 -0
- package/src/language/vocabulary/NexoModule.mjs +1119 -0
- package/src/language/vocabulary/NexoRegistry.mjs +1540 -0
- package/src/language/vocabulary/TypeNameScope.mjs +63 -0
- package/src/language/vocabulary/Vocabulary.mjs +640 -0
- package/src/nexo.d.ts +743 -0
- package/src/nexo.js +338 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer — the tokens of a Nexo text: strictly, as parsing reads them, refusing the first text that is no token, or tolerantly, as tooling reads any text, an invalid token standing for each such text with its diagnostic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NexoErrors } from './NexoError.mjs';
|
|
6
|
+
import { Names } from './Names.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The types a token is, which tooling reads as they are: `invalid` for text that is no token, and `end` for the end of the text, after its last trivia.
|
|
10
|
+
* A string, raw or not, inline or a block, is one literal token keeping its quote and its delimiter's length. An interpolated string is read as its opening, `$"`, `$'` or a block's `$` and quotes and line break,
|
|
11
|
+
* its runs of text, the `{` and `}` around each of its interpolations and what closes it, the tokens of each interpolation's expression between them.
|
|
12
|
+
* A contract within Nexo until a public tooling API stabilizes it.
|
|
13
|
+
*/
|
|
14
|
+
export const TOKEN_TYPES = Object.freeze(['identifier', 'literal', 'keyword', 'it', 'invalid', 'end', 'interpolated-string-start', 'interpolated-text', 'interpolation-start', 'interpolation-end', 'interpolated-string-end', 'script-parameter', 'lexical-reference', '.', '?.', '?[', '?:', '??', '(?', '==', '!=', '<', '<=', '>', '>=', '=', '=>', '+', '-', '*', '/', '%', ',', ':', '(', ')', '{', '}', '[', ']', '..', '^']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} Token
|
|
18
|
+
* @property {'identifier' | 'literal' | 'keyword' | 'it' | 'invalid' | 'end' | 'interpolated-string-start' | 'interpolated-text' | 'interpolation-start' | 'interpolation-end' | 'interpolated-string-end' | 'script-parameter' | 'lexical-reference' | '.' | '?.' | '?[' | '?:' | '??' | '(?' | '==' | '!=' | '<' | '<=' | '>' | '>=' | '=' | '=>' | '+' | '-' | '*' | '/' | '%' | ',' | ':' | '(' | ')' | '{' | '}' | '[' | ']' | '..' | '^'} type
|
|
19
|
+
* @property {unknown} [value] An identifier's or a keyword's text, `it` for the implicit parameter, the identifier of the script parameter `~` reads, the identifier of the local or the field `@` reads, a literal's value, or the text a run of an interpolated string's text stands for.
|
|
20
|
+
* @property {'"' | "'"} [quote] The quote of a string's literal token, or of an interpolated string's opening and closing tokens, `"` for a string and `'` for a raw string.
|
|
21
|
+
* @property {number} [delimiter] How many quotes that string's delimiter has: one inline, three or more for a block.
|
|
22
|
+
* @property {number} start
|
|
23
|
+
* @property {number} end
|
|
24
|
+
* @property {number} triviaStart Where the trivia before it begins, at its start where there is none, so every character of the text belongs to one token or to the trivia before one.
|
|
25
|
+
* @property {boolean} spaced Whether trivia, whitespace or a comment, comes before it, which tells an argument written after a name from one of the name's own.
|
|
26
|
+
* @property {{ code: string, message: string, offset: number }} [diagnostic] Why an invalid token is no token, the code of its condition and where the text first fails.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The names of each family's forms, inline and as a block, by its quote: a string, whose backslashes escape, and a raw string, whose backslashes are text. */
|
|
30
|
+
const DELIMITED_NAMES = Object.freeze({ '"': Object.freeze(['string', 'string block']), "'": Object.freeze(['raw string', 'raw block']) });
|
|
31
|
+
|
|
32
|
+
/** The character each escape a letter or a sign names stands for, after its backslash. */
|
|
33
|
+
const ESCAPES = Object.freeze({ '"': '"', '\\': '\\', n: '\n', r: '\r', t: '\t', e: '\u001B' });
|
|
34
|
+
|
|
35
|
+
/** A Unicode escape after its backslash: `u` and one to six hexadecimal digits in braces. */
|
|
36
|
+
const UNICODE_ESCAPE = /u\{([0-9A-Fa-f]{1,6})\}/y;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The tokens of one character, the arithmetic operators among them: `-` is always an operator, negation or subtraction, so no number begins with it, and `/` is one wherever no comment begins; and `^`, an index from
|
|
40
|
+
* the end, never an exponent.
|
|
41
|
+
*/
|
|
42
|
+
const PUNCTUATION = new Set(['.', ',', ':', '(', ')', '{', '}', '[', ']', '+', '-', '*', '/', '%', '^']);
|
|
43
|
+
const NAME = /[A-Za-z][A-Za-z0-9]*/y;
|
|
44
|
+
/** A decimal number, as JSON writes one, a `_` standing between two of its digits wherever it holds them. */
|
|
45
|
+
const NUMBER = /(?:0|[1-9](?:_?[0-9])*)(?:\.[0-9](?:_?[0-9])*)?(?:[eE][+-]?[0-9](?:_?[0-9])*)?/y;
|
|
46
|
+
/** The prefixes of a whole number in another base, as ECMAScript writes them: its digits, a `_` standing between two, its name and its digits described. */
|
|
47
|
+
const BASES = new Map([
|
|
48
|
+
['x', { digits: /[0-9A-Fa-f](?:_?[0-9A-Fa-f])*/y, name: 'hexadecimal', described: '0 to 9 and a to f' }],
|
|
49
|
+
['o', { digits: /[0-7](?:_?[0-7])*/y, name: 'octal', described: '0 to 7' }],
|
|
50
|
+
['b', { digits: /[01](?:_?[01])*/y, name: 'binary', described: '0 and 1' }],
|
|
51
|
+
]);
|
|
52
|
+
/** The run of letters, digits and underscores an invalid number is refused over. */
|
|
53
|
+
const NUMBER_RUN = /[0-9A-Za-z_]*/y;
|
|
54
|
+
/** Whitespace, as ECMAScript's `\s` matches it, but U+2028 and U+2029, which stand only in a string's literal text. */
|
|
55
|
+
const SPACE = /(?![\u2028\u2029])\s/;
|
|
56
|
+
|
|
57
|
+
/** The line and paragraph separators, which a string's literal text alone holds. */
|
|
58
|
+
const SEPARATORS = /[\u2028\u2029]/;
|
|
59
|
+
|
|
60
|
+
export class Tokenizer {
|
|
61
|
+
#text;
|
|
62
|
+
#offset = 0;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What is being read, innermost last: the text of an interpolated string, with where its `$` begins and its form, or the expression of one of its interpolations, with how many braces its own object expressions have opened,
|
|
66
|
+
* so its `}` is told from theirs, and whether its first token is still to come, which no object expression is.
|
|
67
|
+
* @type {({ kind: 'text', start: number, form: object } | { kind: 'interpolation', depth: number, isFirst: boolean })[]}
|
|
68
|
+
*/
|
|
69
|
+
#modes = [];
|
|
70
|
+
|
|
71
|
+
/** @param {string} text The source text. */
|
|
72
|
+
constructor(text) {
|
|
73
|
+
this.#text = text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every token of the text, ending with an `end` token, read tolerantly: text that is no token is an invalid token holding its diagnostic, and reading goes on after it, so the tokens cover the whole text whatever it holds.
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @returns {Token[]}
|
|
80
|
+
*/
|
|
81
|
+
static scan(text) {
|
|
82
|
+
return new Tokenizer(text).#readAll();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Reads every token of the text, ending with an `end` token, refusing the first text that is no token with its diagnostic, and reading nothing after it. */
|
|
86
|
+
tokenize() {
|
|
87
|
+
return this.#readAll(true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reads every token of the text, ending with an `end` token; a strict reading throws at the first invalid token. Each token but the last moves past some text, so reading always ends. */
|
|
91
|
+
#readAll(isStrict = false) {
|
|
92
|
+
/** @type {Token[]} */
|
|
93
|
+
const tokens = [];
|
|
94
|
+
|
|
95
|
+
for (let token = this.#read(); ; token = this.#read()) {
|
|
96
|
+
if (isStrict && token.type === 'invalid') {
|
|
97
|
+
throw NexoErrors.syntax(token.diagnostic.code, token.diagnostic.message, this.#text, token.diagnostic.offset);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
tokens.push(token);
|
|
101
|
+
|
|
102
|
+
if (token.type === 'end') {
|
|
103
|
+
return tokens;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (token.end <= token.start) {
|
|
107
|
+
throw new Error(`The tokenizer read a ${token.type} token that moves past no text, at offset ${token.start}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#read() {
|
|
113
|
+
// An interpolated string's text holds no trivia: what stands between its tokens is its own text.
|
|
114
|
+
if (this.#modes.at(-1)?.kind === 'text') {
|
|
115
|
+
const token = this.#readInterpolatedStringToken();
|
|
116
|
+
|
|
117
|
+
token.triviaStart = token.start;
|
|
118
|
+
token.spaced = false;
|
|
119
|
+
|
|
120
|
+
return token;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const triviaStart = this.#offset;
|
|
124
|
+
const token = this.#skipTrivia() ?? this.#readToken();
|
|
125
|
+
|
|
126
|
+
token.triviaStart = triviaStart;
|
|
127
|
+
token.spaced = token.start > triviaStart;
|
|
128
|
+
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#readToken() {
|
|
133
|
+
const start = this.#offset;
|
|
134
|
+
const innermost = this.#modes.at(-1);
|
|
135
|
+
const isFirstOfInterpolation = innermost?.kind === 'interpolation' && innermost.isFirst;
|
|
136
|
+
|
|
137
|
+
if (innermost?.kind === 'interpolation') {
|
|
138
|
+
innermost.isFirst = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// An interpolation whose expression begins with an object expression groups it, so its `{` is never read beside the interpolation's own.
|
|
142
|
+
if (isFirstOfInterpolation && this.#text[start] === '{') {
|
|
143
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1063', 'An interpolation that begins with an object expression groups it in parentheses, as {({ x = 1 }.x)}', start);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (start >= this.#text.length) {
|
|
147
|
+
return { type: 'end', start, end: start };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const character = this.#text[start];
|
|
151
|
+
|
|
152
|
+
// `(?` opens an optional enclosed access, one token, so no trivia stands inside it.
|
|
153
|
+
if (this.#text.startsWith('(?', start)) {
|
|
154
|
+
this.#offset += 2;
|
|
155
|
+
|
|
156
|
+
return { type: '(?', start, end: this.#offset };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (character === '$') {
|
|
160
|
+
return this.#readInterpolatedStringStart(start);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const mode = this.#modes.at(-1);
|
|
164
|
+
|
|
165
|
+
// A brace in an interpolation opens or closes an object expression of its expression, and the `}` no object expression is open for closes the interpolation.
|
|
166
|
+
if (mode?.kind === 'interpolation' && (character === '{' || character === '}')) {
|
|
167
|
+
if (character === '{') {
|
|
168
|
+
mode.depth++;
|
|
169
|
+
} else if (mode.depth > 0) {
|
|
170
|
+
mode.depth--;
|
|
171
|
+
} else {
|
|
172
|
+
this.#modes.pop();
|
|
173
|
+
this.#offset++;
|
|
174
|
+
|
|
175
|
+
return { type: 'interpolation-end', start, end: this.#offset };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Two dots are a range's one token, which no member step's dot begins.
|
|
180
|
+
if (this.#text.startsWith('..', start)) {
|
|
181
|
+
this.#offset += 2;
|
|
182
|
+
|
|
183
|
+
return { type: '..', start, end: this.#offset };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (PUNCTUATION.has(character)) {
|
|
187
|
+
this.#offset++;
|
|
188
|
+
|
|
189
|
+
return { type: character, start, end: this.#offset };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (['?.', '?[', '?:', '??', '==', '!=', '<=', '>=', '=>'].some(operator => this.#text.startsWith(operator, start))) {
|
|
193
|
+
this.#offset += 2;
|
|
194
|
+
|
|
195
|
+
return { type: this.#text.slice(start, this.#offset), start, end: this.#offset };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (character === '<' || character === '>') {
|
|
199
|
+
this.#offset++;
|
|
200
|
+
|
|
201
|
+
return { type: character, start, end: this.#offset };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (character === '@') {
|
|
205
|
+
return this.#readLexicalReference(start);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (character === '~') {
|
|
209
|
+
return this.#readScriptParameter(start);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (character === '"' || character === "'") {
|
|
213
|
+
return this.#readString(start);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (character >= '0' && character <= '9') {
|
|
217
|
+
return this.#readNumber(start);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
NAME.lastIndex = start;
|
|
221
|
+
|
|
222
|
+
if (NAME.test(this.#text)) {
|
|
223
|
+
this.#offset = NAME.lastIndex;
|
|
224
|
+
|
|
225
|
+
const name = this.#text.slice(start, this.#offset);
|
|
226
|
+
|
|
227
|
+
if (Names.isLiteral(name)) {
|
|
228
|
+
return { type: 'literal', value: Names.literalValueOf(name), start, end: this.#offset };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `it` is a keyword, given a token type of its own for the parser, which reads it where a value stands.
|
|
232
|
+
if (name === 'it') {
|
|
233
|
+
return { type: 'it', value: name, start, end: this.#offset };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { type: Names.isKeyword(name) ? 'keyword' : 'identifier', value: name, start, end: this.#offset };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A single `=` gives a field, a local or a conditional binding its value, which the parser reads where one stands.
|
|
240
|
+
if (character === '=') {
|
|
241
|
+
this.#offset++;
|
|
242
|
+
|
|
243
|
+
return { type: '=', start, end: this.#offset };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A `?` or a `!` that whitespace parts from the rest of its operator is that operator written apart.
|
|
247
|
+
const following = this.#characterPastSpace(start + 1, 1);
|
|
248
|
+
const preceding = this.#characterPastSpace(start - 1, -1);
|
|
249
|
+
const operator = character === '?' && ['.', '[', ':', '?'].includes(following) ? `?${following}`
|
|
250
|
+
: character === '?' && preceding === '(' ? '(?'
|
|
251
|
+
: character === '!' && following === '=' ? '!='
|
|
252
|
+
: undefined;
|
|
253
|
+
|
|
254
|
+
if (operator !== undefined) {
|
|
255
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1001', `'${operator}' is one operator, written with nothing between its signs`, start);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A single `!` is no operator; an inequality is written `!=`, and a negation `not`.
|
|
259
|
+
if (character === '!') {
|
|
260
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1509', "Unexpected '!': an inequality is written '!=', and a negation 'not'", start);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A `?` written directly after a word or a `>` is a nullable type's notation, `Light?` or `List<Number>?`, which no script writes: the cast or the conversion that names a type is nullable where its operand can be null.
|
|
264
|
+
if (character === '?' && /[A-Za-z0-9>]/.test(this.#text[start - 1] ?? '')) {
|
|
265
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1000', "Unexpected '?': a script names a type bare, and a cast or a conversion answers null where its operand can be null", start);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (Tokenizer.#isLoneSurrogateAt(this.#text, start)) {
|
|
269
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1000', `Unexpected U+${character.charCodeAt(0).toString(16).toUpperCase()}, a surrogate standing alone, which is no character`, start);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A character outside the Basic Multilingual Plane is one character, two code units long.
|
|
273
|
+
const unexpected = String.fromCodePoint(this.#text.codePointAt(start));
|
|
274
|
+
|
|
275
|
+
return this.#readInvalidToken(start, start + unexpected.length, 'NEXO1000', `Unexpected '${unexpected}'`, start);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The character past the whitespace that runs from the offset in the direction, a step of 1 or -1, where at least one whitespace character stands there; undefined otherwise, or where the text ends first. */
|
|
279
|
+
#characterPastSpace(offset, step) {
|
|
280
|
+
let index = offset;
|
|
281
|
+
|
|
282
|
+
while (index >= 0 && index < this.#text.length && /\s/.test(this.#text[index])) {
|
|
283
|
+
index += step;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return index !== offset && index >= 0 && index < this.#text.length ? this.#text[index] : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Moves past the trivia before a token, answering an invalid token for a block comment never closed, which runs to the end of the text; an interpolation stands on one line, so a line break
|
|
291
|
+
* in its trivia, a line comment, which a line break ends, and a block comment holding one are invalid there. U+2028 and U+2029 are invalid in code and in a comment alike, a comment holding one invalid whole.
|
|
292
|
+
*/
|
|
293
|
+
#skipTrivia() {
|
|
294
|
+
const isInInterpolation = this.#modes.at(-1)?.kind === 'interpolation';
|
|
295
|
+
const oneLine = 'An interpolation stands on one line, its { and } with its expression: bind a longer calculation to a local, and interpolate its name';
|
|
296
|
+
|
|
297
|
+
while (this.#offset < this.#text.length) {
|
|
298
|
+
const character = this.#text[this.#offset];
|
|
299
|
+
|
|
300
|
+
if (isInInterpolation && (Tokenizer.#isLineBreak(character) || this.#text.startsWith('//', this.#offset))) {
|
|
301
|
+
return this.#readInvalidToken(this.#offset, this.#offset + 1, 'NEXO1040', oneLine, this.#offset);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (SEPARATORS.test(character)) {
|
|
305
|
+
return this.#readSeparator(this.#offset, this.#offset + 1, this.#offset);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (SPACE.test(character)) {
|
|
309
|
+
this.#offset++;
|
|
310
|
+
} else if (this.#text.startsWith('//', this.#offset)) {
|
|
311
|
+
const lineEnd = this.#lineEndFrom(this.#offset);
|
|
312
|
+
const separator = this.#text.slice(this.#offset, lineEnd).search(SEPARATORS);
|
|
313
|
+
|
|
314
|
+
if (separator !== -1) {
|
|
315
|
+
return this.#readSeparator(this.#offset, lineEnd, this.#offset + separator);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this.#offset = lineEnd >= this.#text.length ? lineEnd : lineEnd + Tokenizer.#lineBreakLengthAt(this.#text, lineEnd);
|
|
319
|
+
} else if (this.#text.startsWith('/*', this.#offset)) {
|
|
320
|
+
const commentEnd = this.#text.indexOf('*/', this.#offset + 2);
|
|
321
|
+
|
|
322
|
+
if (commentEnd === -1) {
|
|
323
|
+
return this.#readInvalidToken(this.#offset, this.#text.length, 'NEXO1007', 'A block comment is never closed', this.#offset);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const separator = this.#text.slice(this.#offset, commentEnd).search(SEPARATORS);
|
|
327
|
+
|
|
328
|
+
if (separator !== -1) {
|
|
329
|
+
return this.#readSeparator(this.#offset, commentEnd + 2, this.#offset + separator);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (isInInterpolation && Tokenizer.holdsLineBreak(this.#text, this.#offset, commentEnd)) {
|
|
333
|
+
return this.#readInvalidToken(this.#offset, commentEnd + 2, 'NEXO1040', oneLine, this.#offset);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.#offset = commentEnd + 2;
|
|
337
|
+
} else {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reads what `@` begins: a lexical reference, the local or the field the identifier written directly after it names, as `@to` reads the local `to`, where the word alone keeps its own meaning, a module's name in text read with it
|
|
347
|
+
* among them, and as `@total` reads `total`, which needs no `@`; invalid where no identifier follows it directly.
|
|
348
|
+
*/
|
|
349
|
+
#readLexicalReference(start) {
|
|
350
|
+
NAME.lastIndex = start + 1;
|
|
351
|
+
|
|
352
|
+
if (!NAME.test(this.#text)) {
|
|
353
|
+
const next = this.#text[start + 1];
|
|
354
|
+
const message = next === undefined || SPACE.test(next)
|
|
355
|
+
? "An identifier follows '@' directly, as @to"
|
|
356
|
+
: "'@' and what follows it are no lexical reference, which writes an identifier directly after '@', as @to";
|
|
357
|
+
|
|
358
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1050', message, start);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const name = this.#text.slice(start + 1, NAME.lastIndex);
|
|
362
|
+
|
|
363
|
+
this.#offset = NAME.lastIndex;
|
|
364
|
+
|
|
365
|
+
return { type: 'lexical-reference', value: name, start, end: this.#offset };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Reads what `~` begins: a script parameter, the one the identifier written directly after it names, whatever local or field shares its name and a reserved word included, as `~as` reads the script parameter `as`;
|
|
370
|
+
* invalid where no name follows it directly, since `~` alone reads nothing.
|
|
371
|
+
*/
|
|
372
|
+
#readScriptParameter(start) {
|
|
373
|
+
NAME.lastIndex = start + 1;
|
|
374
|
+
|
|
375
|
+
if (!NAME.test(this.#text)) {
|
|
376
|
+
const next = this.#text[start + 1];
|
|
377
|
+
const message = next === undefined || SPACE.test(next)
|
|
378
|
+
? "A script parameter is named directly after '~', as ~room"
|
|
379
|
+
: "'~' and what follows it are no script parameter, which is named directly after '~', as ~room";
|
|
380
|
+
|
|
381
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1009', message, start);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const name = this.#text.slice(start + 1, NAME.lastIndex);
|
|
385
|
+
|
|
386
|
+
this.#offset = NAME.lastIndex;
|
|
387
|
+
|
|
388
|
+
return { type: 'script-parameter', value: name, start, end: this.#offset };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Reads a string of either family, a string, whose backslashes escape, or a raw string, whose backslashes are text: inline, one quote and one line, a raw string doubling its quote to hold one,
|
|
393
|
+
* or a block, three or more quotes and a line break opening it and as many closing it, first on their line. Its token keeps its quote and its delimiter's length, and its value is the text it stands for.
|
|
394
|
+
*/
|
|
395
|
+
#readString(start) {
|
|
396
|
+
const quote = this.#text[start];
|
|
397
|
+
const opening = this.#openingOf(start, start, quote, false);
|
|
398
|
+
|
|
399
|
+
if (opening.token !== undefined) {
|
|
400
|
+
return opening.token;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const { form } = opening;
|
|
404
|
+
const scanned = this.#scanText(form.contentStart, form, false);
|
|
405
|
+
|
|
406
|
+
if (scanned.stop === 'close') {
|
|
407
|
+
const end = form.isBlock ? form.closingEnd : scanned.end + 1;
|
|
408
|
+
|
|
409
|
+
this.#offset = end;
|
|
410
|
+
|
|
411
|
+
return scanned.diagnostic === null
|
|
412
|
+
? { type: 'literal', value: scanned.value, start, end, quote, delimiter: form.length }
|
|
413
|
+
: this.#readInvalidToken(start, end, scanned.diagnostic.code, scanned.diagnostic.message, scanned.diagnostic.offset);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// In an interpolation, a quote begins a string of the interpolation's own expression, so one never closed is most often the interpolated string's own quote written before the interpolation's `}`.
|
|
417
|
+
const name = DELIMITED_NAMES[quote][0];
|
|
418
|
+
const message = this.#modes.at(-1)?.kind === 'interpolation' ? `A ${name} is never closed: an interpolation is closed with '}' before its string's closing quote` : `A ${name} is never closed`;
|
|
419
|
+
const unclosed = scanned.diagnostic ?? { code: 'NEXO1510', message, offset: start };
|
|
420
|
+
|
|
421
|
+
return this.#readInvalidToken(start, scanned.end, unclosed.code, unclosed.message, unclosed.offset);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The form of the string whose quotes begin at the offset, after its `$` where it is interpolated: a block where three or more quotes are followed directly by a line break, its closing delimiter
|
|
426
|
+
* found first on a later line, which gives its margin; and an inline string otherwise. An opening a block cannot have, or a block never closed, answers the invalid token that says so.
|
|
427
|
+
*/
|
|
428
|
+
#openingOf(start, quoteStart, quote, isInterpolated) {
|
|
429
|
+
let run = quoteStart;
|
|
430
|
+
|
|
431
|
+
while (this.#text[run] === quote) {
|
|
432
|
+
run++;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const length = run - quoteStart;
|
|
436
|
+
const names = DELIMITED_NAMES[quote];
|
|
437
|
+
const prefix = isInterpolated ? 'interpolated ' : '';
|
|
438
|
+
const isRaw = quote === "'";
|
|
439
|
+
|
|
440
|
+
if (length >= 3) {
|
|
441
|
+
if (Tokenizer.#isLineBreak(this.#text[run])) {
|
|
442
|
+
if (this.#modes.at(-1)?.kind === 'interpolation') {
|
|
443
|
+
return { token: this.#readInvalidToken(start, run, 'NEXO1040', 'An interpolation stands on one line, so it holds no block: bind the block to a local, and interpolate its name', start) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const contentStart = run + Tokenizer.#lineBreakLengthAt(this.#text, run);
|
|
447
|
+
const closing = this.#closingOf(contentStart, quote, length);
|
|
448
|
+
|
|
449
|
+
if (closing === null) {
|
|
450
|
+
return { token: this.#readInvalidToken(start, this.#text.length, 'NEXO1058', `A ${prefix}${names[1]} is never closed: its closing delimiter, ${length} quotes, stands first on a line of its own`, start) };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (closing.diagnostic !== undefined) {
|
|
454
|
+
return { token: this.#readInvalidToken(start, closing.diagnostic.end, closing.diagnostic.code, closing.diagnostic.message, closing.diagnostic.offset) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { form: { quote, length, isRaw, isBlock: true, contentStart, ...closing } };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const lineEnd = this.#lineEndFrom(run);
|
|
461
|
+
|
|
462
|
+
if (/^[ \t]+$/.test(this.#text.slice(run, lineEnd))) {
|
|
463
|
+
return { token: this.#readInvalidToken(start, lineEnd, 'NEXO1038', `A ${prefix}${names[1]}'s opening delimiter is followed directly by its line break, with nothing after it on its line`, run) };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Three double quotes never begin an inline string, whose empty `""` no value follows directly; three single quotes may, the doubled one standing for a quote.
|
|
467
|
+
if (!isRaw) {
|
|
468
|
+
return { token: this.#readInvalidToken(start, run, 'NEXO1038', `A ${prefix}${names[1]}'s text stands on the lines between its delimiters: its opening delimiter is followed directly by a line break, and an inline ${prefix}${names[0]} opens with one quote`, run) };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return { form: { quote, length: 1, isRaw, isBlock: false, contentStart: quoteStart + 1 } };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The closing delimiter of the block whose text begins at the offset: the first line whose text after its indentation is a run of the delimiter's quote, which closes the block where it has the delimiter's length,
|
|
477
|
+
* with the line's indentation its margin and the line break before it where its text ends; a longer run there is a diagnostic, and null where no line has one.
|
|
478
|
+
*/
|
|
479
|
+
#closingOf(contentStart, quote, length) {
|
|
480
|
+
for (let lineStart = contentStart; ;) {
|
|
481
|
+
let offset = lineStart;
|
|
482
|
+
|
|
483
|
+
while (this.#text[offset] === ' ' || this.#text[offset] === '\t') {
|
|
484
|
+
offset++;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (this.#text[offset] === quote) {
|
|
488
|
+
let run = offset;
|
|
489
|
+
|
|
490
|
+
while (this.#text[run] === quote) {
|
|
491
|
+
run++;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (run - offset === length) {
|
|
495
|
+
const contentEnd = lineStart === contentStart ? contentStart : lineStart - (this.#text[lineStart - 2] === '\r' && this.#text[lineStart - 1] === '\n' ? 2 : 1);
|
|
496
|
+
|
|
497
|
+
return { margin: this.#text.slice(lineStart, offset), contentEnd, closingEnd: run };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (run - offset > length) {
|
|
501
|
+
return { diagnostic: { code: 'NEXO1059', message: `A block closes with exactly the ${length} quotes that open it, and this run has ${run - offset}: open it with a longer delimiter to hold the run`, offset, end: run } };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const lineEnd = this.#lineEndFrom(offset);
|
|
506
|
+
|
|
507
|
+
if (lineEnd >= this.#text.length) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
lineStart = lineEnd + Tokenizer.#lineBreakLengthAt(this.#text, lineEnd);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Scans a string's text from the offset in its form: an inline string's up to its closing quote, a block's up to the line break before its closing delimiter, each line's margin removed first,
|
|
517
|
+
* and an interpolated string's up to the `{` that opens an interpolation as well. The value is the text it stands for: escapes decoded in the string family, a raw string's doubled quote one quote,
|
|
518
|
+
* each source line break of a block one line feed, and an interpolated string's doubled brace one brace. The first diagnostic it meets is kept, and `stop` says what ended the scan.
|
|
519
|
+
* @returns {{ value: string, end: number, diagnostic: { code: string, message: string, offset: number } | null, stop: 'close' | 'brace' | 'end' }}
|
|
520
|
+
*/
|
|
521
|
+
#scanText(start, form, isInterpolated) {
|
|
522
|
+
const { quote, isRaw, isBlock } = form;
|
|
523
|
+
let value = '';
|
|
524
|
+
let offset = start;
|
|
525
|
+
let diagnostic = null;
|
|
526
|
+
let isLineStart = isBlock && (start === form.contentStart || Tokenizer.#isLineBreak(this.#text[start - 1]));
|
|
527
|
+
|
|
528
|
+
for (;;) {
|
|
529
|
+
if (isBlock && offset >= form.contentEnd) {
|
|
530
|
+
return { value, end: offset, diagnostic, stop: 'close' };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (offset >= this.#text.length) {
|
|
534
|
+
return { value, end: offset, diagnostic, stop: 'end' };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (isLineStart) {
|
|
538
|
+
const margin = this.#skipMargin(offset, form);
|
|
539
|
+
|
|
540
|
+
diagnostic ??= margin.diagnostic;
|
|
541
|
+
offset = margin.offset;
|
|
542
|
+
isLineStart = false;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const character = this.#text[offset];
|
|
547
|
+
|
|
548
|
+
if (Tokenizer.#isLineBreak(character)) {
|
|
549
|
+
if (!isBlock) {
|
|
550
|
+
const names = DELIMITED_NAMES[quote];
|
|
551
|
+
const prefix = isInterpolated ? 'interpolated ' : '';
|
|
552
|
+
const escape = isRaw ? '' : 'write \\n for a line break, or ';
|
|
553
|
+
const delimiter = `${isInterpolated ? '$' : ''}${quote.repeat(3)}`;
|
|
554
|
+
|
|
555
|
+
diagnostic ??= { code: 'NEXO1037', message: `A${isInterpolated ? 'n' : ''} ${prefix}${names[0]} stands on one line: ${escape}write a${isInterpolated ? 'n' : ''} ${prefix}${names[1]}, its text on the lines between ${delimiter} and ${quote.repeat(3)}`, offset };
|
|
556
|
+
|
|
557
|
+
return { value, end: offset, diagnostic, stop: 'end' };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
value += '\n';
|
|
561
|
+
offset += Tokenizer.#lineBreakLengthAt(this.#text, offset);
|
|
562
|
+
isLineStart = true;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (character === quote) {
|
|
567
|
+
if (!isBlock) {
|
|
568
|
+
if (isRaw && this.#text[offset + 1] === quote) {
|
|
569
|
+
value += quote;
|
|
570
|
+
offset += 2;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { value, end: offset, diagnostic, stop: 'close' };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let run = offset;
|
|
578
|
+
|
|
579
|
+
while (this.#text[run] === quote && run < form.contentEnd) {
|
|
580
|
+
run++;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (run - offset >= form.length) {
|
|
584
|
+
diagnostic ??= { code: 'NEXO1060', message: `A run of ${run - offset} quotes in a block's text is as long as its delimiter: open the block with a longer delimiter to hold it`, offset };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
value += this.#text.slice(offset, run);
|
|
588
|
+
offset = run;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (character === '\\' && !isRaw) {
|
|
593
|
+
const escape = this.#readEscape(offset);
|
|
594
|
+
|
|
595
|
+
if (escape.message !== undefined) {
|
|
596
|
+
diagnostic ??= { code: escape.code, message: escape.message, offset };
|
|
597
|
+
offset++;
|
|
598
|
+
} else {
|
|
599
|
+
value += escape.character;
|
|
600
|
+
offset = escape.end;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (isInterpolated && (character === '{' || character === '}')) {
|
|
607
|
+
if (this.#text[offset + 1] === character) {
|
|
608
|
+
value += character;
|
|
609
|
+
offset += 2;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (character === '{') {
|
|
614
|
+
return { value, end: offset, diagnostic, stop: 'brace' };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
diagnostic ??= { code: 'NEXO1062', message: "A literal '}' in an interpolated string is written '}}'", offset };
|
|
618
|
+
offset++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// A surrogate stands in a string's text only as half of a pair, one character outside the Basic Multilingual Plane, as no escape names one alone.
|
|
623
|
+
if (Tokenizer.#isLoneSurrogateAt(this.#text, offset)) {
|
|
624
|
+
diagnostic ??= { code: 'NEXO1049', message: `A string's text holds a surrogate only as half of a pair, and U+${character.charCodeAt(0).toString(16).toUpperCase()} stands alone`, offset };
|
|
625
|
+
offset++;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
value += character;
|
|
630
|
+
offset++;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Whether the code unit at the offset is a surrogate that is no half of a pair: a high one no low one follows, or a low one no high one precedes. */
|
|
635
|
+
static #isLoneSurrogateAt(text, offset) {
|
|
636
|
+
const unit = text.charCodeAt(offset);
|
|
637
|
+
|
|
638
|
+
if (unit >= 0xD800 && unit <= 0xDBFF) {
|
|
639
|
+
const next = text.charCodeAt(offset + 1);
|
|
640
|
+
|
|
641
|
+
return !(next >= 0xDC00 && next <= 0xDFFF);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (unit >= 0xDC00 && unit <= 0xDFFF) {
|
|
645
|
+
const previous = text.charCodeAt(offset - 1);
|
|
646
|
+
|
|
647
|
+
return !(previous >= 0xD800 && previous <= 0xDBFF);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Moves past the margin at the start of a block's line: the closing delimiter's indentation, which a line that is not blank begins with exactly; a blank line shorter than the margin is empty,
|
|
655
|
+
* and one that begins with it keeps the whitespace after it.
|
|
656
|
+
*/
|
|
657
|
+
#skipMargin(lineStart, form) {
|
|
658
|
+
const { margin } = form;
|
|
659
|
+
const lineEnd = Math.min(this.#lineEndFrom(lineStart), form.contentEnd);
|
|
660
|
+
const line = this.#text.slice(lineStart, lineEnd);
|
|
661
|
+
|
|
662
|
+
if (line.startsWith(margin)) {
|
|
663
|
+
return { offset: lineStart + margin.length, diagnostic: null };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (/^[ \t]*$/.test(line)) {
|
|
667
|
+
return { offset: lineEnd, diagnostic: null };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return { offset: lineStart, diagnostic: { code: 'NEXO1061', message: "Each line of a block that is not blank begins with its closing delimiter's indentation, the margin removed from its lines, and this one does not", offset: lineStart } };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** An invalid token from the start to the end, for the line or the paragraph separator at the offset, which a string's literal text alone holds, as a character of its value (NEXO1002). */
|
|
674
|
+
#readSeparator(start, end, offset) {
|
|
675
|
+
const name = this.#text[offset] === '\u2028' ? 'U+2028, the line separator,' : 'U+2029, the paragraph separator,';
|
|
676
|
+
|
|
677
|
+
return this.#readInvalidToken(start, end, 'NEXO1002', `${name} stands only in a string's literal text, as a character of its value`, offset);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Where the line the offset stands on ends: at its line break, a line feed, a carriage return or both, or at the end of the text. */
|
|
681
|
+
#lineEndFrom(offset) {
|
|
682
|
+
let end = offset;
|
|
683
|
+
|
|
684
|
+
while (end < this.#text.length && !Tokenizer.#isLineBreak(this.#text[end])) {
|
|
685
|
+
end++;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return end;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Whether the text holds a line break between the offsets, LF, CRLF or a lone CR, in its trivia or a token alike, as the gap between two tokens does where a line breaks there. */
|
|
692
|
+
static holdsLineBreak(text, start, end) {
|
|
693
|
+
for (let offset = start; offset < end; offset++) {
|
|
694
|
+
if (Tokenizer.#isLineBreak(text[offset])) {
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Whether the character breaks a line: a line feed, or a carriage return, alone or before a line feed. */
|
|
703
|
+
static #isLineBreak(character) {
|
|
704
|
+
return character === '\n' || character === '\r';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** How many characters the line break at the offset has: two for a carriage return and a line feed, one otherwise. */
|
|
708
|
+
static #lineBreakLengthAt(text, offset) {
|
|
709
|
+
return text[offset] === '\r' && text[offset + 1] === '\n' ? 2 : 1;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Reads what `$` begins: an interpolated string's `$"` or `$'`, or its block's three or more quotes and line break, where the quote follows it directly, pushing the mode its text is read in, and otherwise text that is no token, a space between them saying where it stands. */
|
|
713
|
+
#readInterpolatedStringStart(start) {
|
|
714
|
+
const quote = this.#text[start + 1];
|
|
715
|
+
|
|
716
|
+
if (quote === '"' || quote === "'") {
|
|
717
|
+
const opening = this.#openingOf(start, start + 1, quote, true);
|
|
718
|
+
|
|
719
|
+
if (opening.token !== undefined) {
|
|
720
|
+
return opening.token;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const { form } = opening;
|
|
724
|
+
|
|
725
|
+
this.#offset = form.contentStart;
|
|
726
|
+
this.#modes.push({ kind: 'text', start, form });
|
|
727
|
+
|
|
728
|
+
return { type: 'interpolated-string-start', start, end: this.#offset, quote, delimiter: form.length };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let offset = start + 1;
|
|
732
|
+
|
|
733
|
+
while (offset < this.#text.length && SPACE.test(this.#text[offset])) {
|
|
734
|
+
offset++;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const message = offset > start + 1 && (this.#text[offset] === '"' || this.#text[offset] === "'") ? `An interpolated string starts with '$${this.#text[offset]}', with no space between them` : "Unexpected '$'";
|
|
738
|
+
|
|
739
|
+
return this.#readInvalidToken(start, start + 1, 'NEXO1005', message, start);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Reads at the offset in an interpolated string's text, outside its interpolations: what closes it, its closing quote or a block's closing delimiter, the `{` that opens an interpolation, or a run of its text up to either,
|
|
744
|
+
* scanned as its form reads text. A lone `}` and an escape the language has none of make the run invalid, as text never closed does, which ends the string at the end of the text.
|
|
745
|
+
*/
|
|
746
|
+
#readInterpolatedStringToken() {
|
|
747
|
+
const start = this.#offset;
|
|
748
|
+
const mode = this.#modes.at(-1);
|
|
749
|
+
const { form } = mode;
|
|
750
|
+
const isLineStart = form.isBlock && (start === form.contentStart || Tokenizer.#isLineBreak(this.#text[start - 1]));
|
|
751
|
+
|
|
752
|
+
if (form.isBlock ? start >= form.contentEnd : this.#text[start] === form.quote && !(form.isRaw && this.#text[start + 1] === form.quote)) {
|
|
753
|
+
this.#modes.pop();
|
|
754
|
+
this.#offset = form.isBlock ? form.closingEnd : start + 1;
|
|
755
|
+
|
|
756
|
+
return { type: 'interpolated-string-end', start, end: this.#offset, quote: form.quote, delimiter: form.length };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// At a block's line start the margin is removed first, so a brace there opens an interpolation only where the margin is empty.
|
|
760
|
+
if ((!isLineStart || form.margin === '') && this.#text[start] === '{' && this.#text[start + 1] !== '{') {
|
|
761
|
+
this.#modes.push({ kind: 'interpolation', depth: 0, isFirst: true });
|
|
762
|
+
this.#offset = start + 1;
|
|
763
|
+
|
|
764
|
+
return { type: 'interpolation-start', start, end: this.#offset };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const scanned = this.#scanText(start, form, true);
|
|
768
|
+
|
|
769
|
+
if (scanned.stop === 'end') {
|
|
770
|
+
this.#modes.pop();
|
|
771
|
+
|
|
772
|
+
const names = DELIMITED_NAMES[form.quote];
|
|
773
|
+
|
|
774
|
+
return this.#readInvalidToken(start, scanned.end, scanned.diagnostic?.code ?? 'NEXO1510', scanned.diagnostic?.message ?? `An interpolated ${names[0]} is never closed`, scanned.diagnostic?.offset ?? mode.start);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (scanned.diagnostic !== null) {
|
|
778
|
+
return this.#readInvalidToken(start, scanned.end, scanned.diagnostic.code, scanned.diagnostic.message, scanned.diagnostic.offset);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
this.#offset = scanned.end;
|
|
782
|
+
|
|
783
|
+
return { type: 'interpolated-text', value: scanned.value, start, end: scanned.end };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Reads the escape whose backslash is at the offset: the character it stands for and where it ends, or the code and the message that say why it is none the language has. */
|
|
787
|
+
#readEscape(offset) {
|
|
788
|
+
const escaped = this.#text[offset + 1];
|
|
789
|
+
|
|
790
|
+
if (escaped !== 'u') {
|
|
791
|
+
return Object.hasOwn(ESCAPES, escaped ?? '')
|
|
792
|
+
? { character: ESCAPES[escaped], end: offset + 2 }
|
|
793
|
+
: { code: 'NEXO1006', message: 'A string escapes only \\", \\\\, \\n, \\r, \\t, \\e and \\u{…}' };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
UNICODE_ESCAPE.lastIndex = offset + 1;
|
|
797
|
+
|
|
798
|
+
const match = UNICODE_ESCAPE.exec(this.#text);
|
|
799
|
+
|
|
800
|
+
if (match === null) {
|
|
801
|
+
return { code: 'NEXO1006', message: 'A Unicode escape is written \\u{…} with one to six hexadecimal digits, as \\u{1F600}' };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const codePoint = Number.parseInt(match[1], 16);
|
|
805
|
+
|
|
806
|
+
if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
|
|
807
|
+
return { code: 'NEXO1049', message: 'A Unicode escape names a scalar value, and D800 to DFFF are surrogates' };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (codePoint > 0x10FFFF) {
|
|
811
|
+
return { code: 'NEXO1049', message: 'A Unicode escape names a value no greater than 10FFFF' };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return { character: String.fromCodePoint(codePoint), end: UNICODE_ESCAPE.lastIndex };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Reads the number a digit at the offset starts: a decimal number as JSON writes it, or a whole number after `0x`, `0o` or `0b` in hexadecimal, octal or binary, as ECMAScript writes it, a `_` standing between
|
|
819
|
+
* two digits of either. A zero a digit follows, as in `0123`, an exponent with no digits, as in `1e` or `1e+`, and a `_` anywhere but between two digits make the run invalid, which a reading of the number
|
|
820
|
+
* alone would split into a number and the name or the number after it. A letter right after the number makes it invalid too, over the name it starts, as in `1else`, so a number and a name after it always
|
|
821
|
+
* stand apart. Its value is the nearest number to the digits, as ECMAScript reads them.
|
|
822
|
+
*/
|
|
823
|
+
#readNumber(start) {
|
|
824
|
+
const prefix = this.#text[start] === '0' ? this.#text[start + 1] : undefined;
|
|
825
|
+
|
|
826
|
+
if (prefix !== undefined && BASES.has(prefix.toLowerCase())) {
|
|
827
|
+
return this.#readNumberInBase(start, prefix);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
NUMBER.lastIndex = start;
|
|
831
|
+
NUMBER.test(this.#text);
|
|
832
|
+
|
|
833
|
+
const end = NUMBER.lastIndex;
|
|
834
|
+
const digits = this.#text.slice(start, end);
|
|
835
|
+
const malformed = /[0-9]+|[eE][+-]/y;
|
|
836
|
+
const named = /[A-Za-z][A-Za-z0-9]*/y;
|
|
837
|
+
|
|
838
|
+
malformed.lastIndex = end;
|
|
839
|
+
named.lastIndex = end;
|
|
840
|
+
|
|
841
|
+
if (malformed.test(this.#text)) {
|
|
842
|
+
const message = /^[0-9]/.test(this.#text[end]) ? "No zero leads a number's digits" : "A number's exponent has digits of its own";
|
|
843
|
+
|
|
844
|
+
return this.#readInvalidToken(start, malformed.lastIndex, 'NEXO1000', message, start);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (this.#text[end] === '_') {
|
|
848
|
+
return this.#readInvalidToken(start, this.#endOfNumberRun(end), 'NEXO1000', digits === '0' ? "No zero leads a number's digits" : "A '_' in a number stands between two digits", start);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (named.test(this.#text)) {
|
|
852
|
+
const name = this.#text.slice(end, named.lastIndex);
|
|
853
|
+
const message = /^[eE]$/.test(name) ? "A number's exponent has digits of its own" : `A number stands apart from a name after it: write '${digits} ${name}'`;
|
|
854
|
+
|
|
855
|
+
return this.#readInvalidToken(start, named.lastIndex, 'NEXO1000', message, start);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
this.#offset = end;
|
|
859
|
+
|
|
860
|
+
return { type: 'literal', value: Number(digits.replaceAll('_', '')), start, end };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Reads the whole number in hexadecimal, octal or binary a prefix at the offset starts, `0x`, `0o` or `0b`, written in lowercase: its digits follow it, a `_` standing between two. A prefix in capitals, a prefix
|
|
865
|
+
* with no digit after it, a digit its base lacks, a letter, a fraction and a `_` anywhere but between two digits make the run invalid.
|
|
866
|
+
*/
|
|
867
|
+
#readNumberInBase(start, prefix) {
|
|
868
|
+
const { digits, name, described } = BASES.get(prefix.toLowerCase());
|
|
869
|
+
const invalid = (end, message) => this.#readInvalidToken(start, end, 'NEXO1000', message, start);
|
|
870
|
+
|
|
871
|
+
if (prefix !== prefix.toLowerCase()) {
|
|
872
|
+
const end = this.#endOfNumberRun(start + 2);
|
|
873
|
+
|
|
874
|
+
return invalid(end, `A number's prefix is written in lowercase: write '0${prefix.toLowerCase()}${this.#text.slice(start + 2, end)}'`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
digits.lastIndex = start + 2;
|
|
878
|
+
|
|
879
|
+
if (!digits.test(this.#text)) {
|
|
880
|
+
return invalid(this.#endOfNumberRun(start + 2), `'0${prefix}' begins a ${name} number, and its digits, ${described}, follow it`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const end = digits.lastIndex;
|
|
884
|
+
const next = this.#text[end] ?? '';
|
|
885
|
+
|
|
886
|
+
if (next === '_') {
|
|
887
|
+
return invalid(this.#endOfNumberRun(end), "A '_' in a number stands between two digits");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (/[0-9A-Za-z]/.test(next)) {
|
|
891
|
+
return invalid(this.#endOfNumberRun(end), `A ${name} number's digits are ${described}, and a name after a number stands apart from it`);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (next === '.' && /[0-9]/.test(this.#text[end + 1] ?? '')) {
|
|
895
|
+
return invalid(this.#endOfNumberRun(end + 1), `A ${name} number is whole, and has no fraction`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
this.#offset = end;
|
|
899
|
+
|
|
900
|
+
return { type: 'literal', value: Number(`0${prefix}${this.#text.slice(start + 2, end).replaceAll('_', '')}`), start, end };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** The end of the run of letters, digits and underscores from the offset, which an invalid number is refused over. */
|
|
904
|
+
#endOfNumberRun(offset) {
|
|
905
|
+
NUMBER_RUN.lastIndex = offset;
|
|
906
|
+
NUMBER_RUN.test(this.#text);
|
|
907
|
+
|
|
908
|
+
return NUMBER_RUN.lastIndex;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Reads an invalid token over the text from the start to the end, advancing the offset to its end, where reading goes on, with the code, the message and the offset its diagnostic gives. */
|
|
912
|
+
#readInvalidToken(start, end, code, message, offset) {
|
|
913
|
+
this.#offset = end;
|
|
914
|
+
|
|
915
|
+
return { type: 'invalid', start, end, diagnostic: { code, message, offset } };
|
|
916
|
+
}
|
|
917
|
+
}
|