@descent-vtt/spec-guard 0.25.0 → 0.27.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/CHANGELOG.md +107 -0
- package/README.md +32 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +66 -9
- package/dist/cli.js.map +1 -1
- package/dist/imports.d.ts +1 -1
- package/dist/imports.d.ts.map +1 -1
- package/dist/polyglot.d.ts +21 -37
- package/dist/polyglot.d.ts.map +1 -1
- package/dist/polyglot.js +509 -215
- package/dist/polyglot.js.map +1 -1
- package/package.json +1 -1
package/dist/polyglot.js
CHANGED
|
@@ -134,11 +134,12 @@ export function literalValue(raw) {
|
|
|
134
134
|
/* ------------------------------------------------------------------ reading */
|
|
135
135
|
const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
136
136
|
/**
|
|
137
|
-
* Whitespace-skipping cursor over masked code
|
|
137
|
+
* Whitespace-skipping cursor over masked code, which a Go import and a Python
|
|
138
|
+
* dynamic import are read with.
|
|
138
139
|
*
|
|
139
140
|
* Exported so its edges can be pinned directly. Reaching "what does peek() do
|
|
140
|
-
* at the end of the file" through a
|
|
141
|
-
* nothing about
|
|
141
|
+
* at the end of the file" through a Go source string is possible but says
|
|
142
|
+
* nothing about Go.
|
|
142
143
|
*/
|
|
143
144
|
export class Reader {
|
|
144
145
|
text;
|
|
@@ -160,26 +161,6 @@ export class Reader {
|
|
|
160
161
|
peek() {
|
|
161
162
|
return this.text[this.index] ?? '';
|
|
162
163
|
}
|
|
163
|
-
eat(token) {
|
|
164
|
-
if (!this.text.startsWith(token, this.index))
|
|
165
|
-
return false;
|
|
166
|
-
this.index += token.length;
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Eats a word only when it really is the whole word.
|
|
171
|
-
*
|
|
172
|
-
* `eat('static')` also matches the first six characters of a namespace called
|
|
173
|
-
* `staticfiles`, and would then read the dependency as `files`.
|
|
174
|
-
*/
|
|
175
|
-
keyword(word) {
|
|
176
|
-
if (!this.text.startsWith(word, this.index))
|
|
177
|
-
return false;
|
|
178
|
-
if (/[A-Za-z0-9_]/.test(this.text[this.index + word.length] ?? ''))
|
|
179
|
-
return false;
|
|
180
|
-
this.index += word.length;
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
164
|
identifier() {
|
|
184
165
|
IDENTIFIER.lastIndex = this.index;
|
|
185
166
|
const match = IDENTIFIER.exec(this.text);
|
|
@@ -188,49 +169,6 @@ export class Reader {
|
|
|
188
169
|
this.index += match[0].length;
|
|
189
170
|
return match[0];
|
|
190
171
|
}
|
|
191
|
-
/**
|
|
192
|
-
* Reads `a.b.c`, leaving out a C# alias qualifier.
|
|
193
|
-
*
|
|
194
|
-
* `global::A.B` and `Legacy::A.B` both name the namespace `A.B`: what comes
|
|
195
|
-
* before `::` says where to look it up - the global namespace, or the
|
|
196
|
-
* assembly an extern alias names - and is not part of the name. Until 0.10.2
|
|
197
|
-
* this read `global.A.B`, and no pattern written for `A.B` matched it.
|
|
198
|
-
*/
|
|
199
|
-
dotted() {
|
|
200
|
-
let parts = [];
|
|
201
|
-
for (;;) {
|
|
202
|
-
const part = this.identifier();
|
|
203
|
-
if (!part)
|
|
204
|
-
break;
|
|
205
|
-
parts.push(part);
|
|
206
|
-
if (this.eat('::'))
|
|
207
|
-
parts = [];
|
|
208
|
-
else if (!this.eat('.'))
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
return parts.join('.');
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Skips a generic argument list, so an alias target's `<...>` does not end
|
|
215
|
-
* the read.
|
|
216
|
-
*
|
|
217
|
-
* No bracket counting. A `using` directive ends at the first `;` or newline
|
|
218
|
-
* whatever is nested inside its angle brackets, and the caller's only
|
|
219
|
-
* question is whether the next non-space character is that `;` - so `A<B>`
|
|
220
|
-
* and `A<B<C>>` are both passed over by the same rule. The depth counter
|
|
221
|
-
* that used to be here could not change an answer for any input C# accepts,
|
|
222
|
-
* which is another way of saying no test could hold it in place.
|
|
223
|
-
*/
|
|
224
|
-
skipGenerics() {
|
|
225
|
-
if (this.peek() !== '<')
|
|
226
|
-
return;
|
|
227
|
-
while (this.index < this.text.length) {
|
|
228
|
-
const char = this.text[this.index];
|
|
229
|
-
if (char === ';' || char === '\n')
|
|
230
|
-
return;
|
|
231
|
-
this.index += 1;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
172
|
}
|
|
235
173
|
/** Offset of the matching close, given the depth-tracking characters. */
|
|
236
174
|
export function matchingClose(text, open, opener, closer) {
|
|
@@ -268,20 +206,27 @@ function note(collector, kind, offset, detail) {
|
|
|
268
206
|
*
|
|
269
207
|
* A line ends where Python ends one: at LF, at CRLF and at a lone CR. Only
|
|
270
208
|
* LF ended one here, so every line of a file with CRLF endings kept its
|
|
271
|
-
* carriage return. The pattern that
|
|
209
|
+
* carriage return. The pattern that read a `from` statement could not reach
|
|
272
210
|
* the end of such a line, and read none in the file, and a backslash before
|
|
273
211
|
* the carriage return continued nothing (ADR-0008, amended 2026-10-12).
|
|
212
|
+
*
|
|
213
|
+
* `starts` is where each physical line begins, in the text and in the
|
|
214
|
+
* source. A statement after a `;` stands on a line of its own where the
|
|
215
|
+
* statement before it ran over several, and is reported there (ADR-0008,
|
|
216
|
+
* amended 2026-10-14).
|
|
274
217
|
*/
|
|
275
218
|
export function logicalLines(code) {
|
|
276
219
|
const lines = [];
|
|
277
220
|
let text = '';
|
|
278
221
|
let offset = 0;
|
|
222
|
+
let starts = [];
|
|
279
223
|
let depth = 0;
|
|
280
224
|
let start = 0;
|
|
281
225
|
const flush = () => {
|
|
282
226
|
if (text.trim())
|
|
283
|
-
lines.push({ text, offset });
|
|
227
|
+
lines.push({ text, offset, starts });
|
|
284
228
|
text = '';
|
|
229
|
+
starts = [];
|
|
285
230
|
};
|
|
286
231
|
for (let index = 0; index <= code.length; index++) {
|
|
287
232
|
const char = code[index];
|
|
@@ -295,7 +240,10 @@ export function logicalLines(code) {
|
|
|
295
240
|
const physical = code.slice(start, index);
|
|
296
241
|
if (!text)
|
|
297
242
|
offset = start;
|
|
298
|
-
|
|
243
|
+
else
|
|
244
|
+
text += ' ';
|
|
245
|
+
starts.push([text.length, start]);
|
|
246
|
+
text += physical.replace(/\\$/, ' ');
|
|
299
247
|
if (depth === 0 && !/\\$/.test(physical))
|
|
300
248
|
flush();
|
|
301
249
|
// The two characters of a CRLF end one line. Read as two, the empty
|
|
@@ -307,12 +255,122 @@ export function logicalLines(code) {
|
|
|
307
255
|
flush();
|
|
308
256
|
return lines;
|
|
309
257
|
}
|
|
310
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Where an import statement may start: at the head of a logical line, or
|
|
260
|
+
* after `;` or `:`. What separates tokens is a blank, a tab or a form feed,
|
|
261
|
+
* as Python has it, and `lazy` before the keyword is Python 3.15's lazy
|
|
262
|
+
* import. The keyword is a whole word, and nothing need follow it but its
|
|
263
|
+
* next token: `from.a import(b)` is an import Python reads.
|
|
264
|
+
*/
|
|
265
|
+
const PYTHON_STATEMENT = /(^|[;:])([ \t\f]*)(lazy[ \t\f]+)?(import|from)(?!\p{XID_Continue})/gu;
|
|
266
|
+
/** One token of an import statement, after any blanks: a name, one of its marks, or its end at a `;` or at the end of the line. */
|
|
267
|
+
const PYTHON_TOKEN = /[ \t\f]*(?:([\p{XID_Start}_]\p{XID_Continue}*)|([.,()*])|;|$)/uy;
|
|
268
|
+
/** What is no name: the marks of an import statement, and the `;` that ends it. */
|
|
269
|
+
const PYTHON_MARKS = '.,()*;';
|
|
270
|
+
/**
|
|
271
|
+
* The tokens of the statement that goes on from `from`, with `;` for its
|
|
272
|
+
* end whichever way it ended, or null where a character is no part of an
|
|
273
|
+
* import statement.
|
|
274
|
+
*/
|
|
275
|
+
function pythonTokens(text, from) {
|
|
276
|
+
const tokens = [];
|
|
277
|
+
PYTHON_TOKEN.lastIndex = from;
|
|
278
|
+
for (;;) {
|
|
279
|
+
const match = PYTHON_TOKEN.exec(text);
|
|
280
|
+
if (match === null)
|
|
281
|
+
return null;
|
|
282
|
+
const token = match[1] ?? match[2];
|
|
283
|
+
if (token === undefined)
|
|
284
|
+
return [...tokens, ';'];
|
|
285
|
+
tokens.push(token);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The modules one import statement names, read as Python's grammar has it,
|
|
290
|
+
* or null for a statement Python refuses: an import it reads is read here,
|
|
291
|
+
* and what it refuses stays unread (ADR-0008, amended 2026-10-14).
|
|
292
|
+
*
|
|
293
|
+
* import a.b as c, d
|
|
294
|
+
* from ..a.b import c as d, e from . import (a, b,) from a import *
|
|
295
|
+
*
|
|
296
|
+
* A name is not held to Python's keywords, and a statement to nothing but
|
|
297
|
+
* its own grammar: what scope it stands in, and whether the rest of its file
|
|
298
|
+
* parses, are not asked.
|
|
299
|
+
*/
|
|
300
|
+
function pythonImports(tokens, from, lazy) {
|
|
301
|
+
let at = 0;
|
|
302
|
+
const eat = (mark) => {
|
|
303
|
+
if (tokens[at] !== mark)
|
|
304
|
+
return false;
|
|
305
|
+
at += 1;
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
// A name, or nothing where a mark stands. The place is never past the `;`
|
|
309
|
+
// a statement ends with, which is a mark too.
|
|
310
|
+
const name = () => {
|
|
311
|
+
const token = tokens[at];
|
|
312
|
+
if (PYTHON_MARKS.includes(token))
|
|
313
|
+
return '';
|
|
314
|
+
at += 1;
|
|
315
|
+
return token;
|
|
316
|
+
};
|
|
317
|
+
const dotted = () => {
|
|
318
|
+
const parts = [];
|
|
319
|
+
do {
|
|
320
|
+
const part = name();
|
|
321
|
+
if (part === '')
|
|
322
|
+
return '';
|
|
323
|
+
parts.push(part);
|
|
324
|
+
} while (eat('.'));
|
|
325
|
+
return parts.join('.');
|
|
326
|
+
};
|
|
327
|
+
// `as` and the name after it, where there is one.
|
|
328
|
+
const renamed = () => !eat('as') || name() !== '';
|
|
329
|
+
if (!from) {
|
|
330
|
+
const modules = [];
|
|
331
|
+
do {
|
|
332
|
+
const module = dotted();
|
|
333
|
+
if (module === '' || !renamed())
|
|
334
|
+
return null;
|
|
335
|
+
modules.push(module);
|
|
336
|
+
} while (eat(','));
|
|
337
|
+
return eat(';') ? modules : null;
|
|
338
|
+
}
|
|
339
|
+
let dots = '';
|
|
340
|
+
while (eat('.'))
|
|
341
|
+
dots += '.';
|
|
342
|
+
// Dots, a module or both stand before `import`. A module that is no name
|
|
343
|
+
// leaves the place at what it could not read, which is not `import`.
|
|
344
|
+
const module = tokens[at] === 'import' ? '' : dotted();
|
|
345
|
+
if (`${dots}${module}` === '' || !eat('import'))
|
|
346
|
+
return null;
|
|
347
|
+
const names = [];
|
|
348
|
+
if (eat('*')) {
|
|
349
|
+
// Python 3.15 refuses a star import that is lazy.
|
|
350
|
+
if (lazy)
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const open = eat('(');
|
|
355
|
+
do {
|
|
356
|
+
const imported = name();
|
|
357
|
+
if (imported === '' || !renamed())
|
|
358
|
+
return null;
|
|
359
|
+
names.push(imported);
|
|
360
|
+
// A comma before `)` is the last, which only parentheses take: where
|
|
361
|
+
// none was opened the `)` is left standing, and ends no statement.
|
|
362
|
+
} while (eat(',') && tokens[at] !== ')');
|
|
363
|
+
if (open && !eat(')'))
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (!eat(';'))
|
|
367
|
+
return null;
|
|
368
|
+
if (module !== '')
|
|
369
|
+
return [`${dots}${module}`];
|
|
370
|
+
// `from . import a, b` - here the imported names are themselves modules,
|
|
371
|
+
// which `from .pkg import name` cannot assume.
|
|
372
|
+
return names.slice(0, MAX_EXPANSION).map((imported) => `${dots}${imported}`);
|
|
373
|
+
}
|
|
316
374
|
const PYTHON_DYNAMIC = /\b(__import__|import_module)[ \t]*\(/g;
|
|
317
375
|
function readPython(collector) {
|
|
318
376
|
const { code } = collector.masked;
|
|
@@ -320,36 +378,23 @@ function readPython(collector) {
|
|
|
320
378
|
PYTHON_STATEMENT.lastIndex = 0;
|
|
321
379
|
let match;
|
|
322
380
|
while ((match = PYTHON_STATEMENT.exec(line.text)) !== null) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
const from = PYTHON_FROM.exec(rest);
|
|
337
|
-
if (!from)
|
|
338
|
-
continue;
|
|
339
|
-
const dots = from[1];
|
|
340
|
-
const module = from[2] ?? '';
|
|
341
|
-
if (module || !dots) {
|
|
342
|
-
emit(collector, `${dots}${module}`, 'import', line.offset);
|
|
381
|
+
const tokens = pythonTokens(line.text, PYTHON_STATEMENT.lastIndex);
|
|
382
|
+
const modules = tokens === null ? null : pythonImports(tokens, match[4] === 'from', match[3] !== undefined);
|
|
383
|
+
// Reported on the line its first word stands on, at that line's head,
|
|
384
|
+
// as an import at the head of a logical line always was.
|
|
385
|
+
const first = match.index + match[1].length + match[2].length;
|
|
386
|
+
const [, offset] = line.starts.findLast(([at]) => at <= first);
|
|
387
|
+
// A statement that starts as an import and that Python refuses names no
|
|
388
|
+
// module, and is said, as everything that could not be read is: a rule
|
|
389
|
+
// that passes over it under --strict fails (ADR-0008, amended
|
|
390
|
+
// 2026-10-18).
|
|
391
|
+
if (modules === null) {
|
|
392
|
+
note(collector, 'refused', offset, 'a statement that starts as an import is one Python refuses, and no module is read from it');
|
|
343
393
|
continue;
|
|
344
394
|
}
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
for (const clause of names.split(',').slice(0, MAX_EXPANSION)) {
|
|
349
|
-
const name = clause.trim().split(/\s+/)[0];
|
|
350
|
-
if (name && name !== '*')
|
|
351
|
-
emit(collector, `${dots}${name}`, 'import', line.offset);
|
|
352
|
-
}
|
|
395
|
+
// Python reads a name in the form NFKC gives it.
|
|
396
|
+
for (const module of modules)
|
|
397
|
+
emit(collector, module.normalize('NFKC'), 'import', offset);
|
|
353
398
|
}
|
|
354
399
|
}
|
|
355
400
|
PYTHON_DYNAMIC.lastIndex = 0;
|
|
@@ -395,58 +440,148 @@ function readGo(collector) {
|
|
|
395
440
|
}
|
|
396
441
|
}
|
|
397
442
|
/* --------------------------------------------------------------------- rust */
|
|
398
|
-
|
|
443
|
+
/** Rust's white space, Unicode's Pattern_White_Space, which a masked comment is too. */
|
|
444
|
+
const RUST_SPACE = String.raw `\t\n\v\f\r \u{85}\u{200e}\u{200f}\u{2028}\u{2029}`;
|
|
445
|
+
/**
|
|
446
|
+
* Where a use declaration or an extern crate starts. The keyword is a whole
|
|
447
|
+
* word, so not the tail of `r#use`, a raw identifier.
|
|
448
|
+
*/
|
|
449
|
+
const RUST_USE = new RegExp(String.raw `(?<![\p{XID_Continue}#])(?:use|extern[${RUST_SPACE}]+crate)(?!\p{XID_Continue})`, 'gu');
|
|
399
450
|
/**
|
|
400
|
-
*
|
|
451
|
+
* One token of a use declaration, after any white space: a name, raw or not,
|
|
452
|
+
* `$crate` or another of a macro's names, or a mark. Nothing need separate two
|
|
453
|
+
* tokens that cannot be read as one.
|
|
454
|
+
*/
|
|
455
|
+
const RUST_TOKEN = new RegExp(String.raw `[${RUST_SPACE}]*(?:(r#)?([\p{XID_Start}_]\p{XID_Continue}*)|(\$[\p{XID_Start}_]\p{XID_Continue}*|::|[{},*;]))`, 'uy');
|
|
456
|
+
/** Nothing but white space, to the end of the text. */
|
|
457
|
+
const RUST_BLANK = new RegExp(String.raw `^[${RUST_SPACE}]*$`, 'u');
|
|
458
|
+
/** What `r#` cannot make a raw identifier of. */
|
|
459
|
+
const RUST_NOT_RAW = new Set(['_', 'crate', 'self', 'Self', 'super']);
|
|
460
|
+
/** What no segment of a path is unless written raw: `_`, and the keywords a declaration starts with. */
|
|
461
|
+
const RUST_NOT_SEGMENT = new Set(['_', 'use', 'extern']);
|
|
462
|
+
/** The token at `index`, or null where none starts. */
|
|
463
|
+
function rustToken(text, index) {
|
|
464
|
+
RUST_TOKEN.lastIndex = index;
|
|
465
|
+
const match = RUST_TOKEN.exec(text);
|
|
466
|
+
if (match === null)
|
|
467
|
+
return null;
|
|
468
|
+
const [, raw, word, mark] = match;
|
|
469
|
+
// `r#` stands only before a name.
|
|
470
|
+
if (raw !== undefined && RUST_NOT_RAW.has(word))
|
|
471
|
+
return null;
|
|
472
|
+
return { text: word?.normalize('NFC') ?? mark, name: word !== undefined, raw: raw !== undefined, end: RUST_TOKEN.lastIndex };
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The paths one use tree names, segment by segment, read as the Rust
|
|
476
|
+
* Reference's grammar has it:
|
|
477
|
+
*
|
|
478
|
+
* (path ::)? * (path ::)? { tree, ... } path (as name)?
|
|
401
479
|
*
|
|
402
|
-
* `
|
|
403
|
-
*
|
|
404
|
-
*
|
|
480
|
+
* `refused` where rustc would refuse the text, and `overflow` past
|
|
481
|
+
* MAX_EXPANSION paths or groups nested that deep. `end` is where reading
|
|
482
|
+
* stopped, past the tree or at the token it could not read.
|
|
405
483
|
*/
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// alias is read, and white space left at its end is taken off on the next
|
|
412
|
-
// line: one space on either side of `as` splits at a place that reads the
|
|
413
|
-
// same, so the pattern without either `+` is an equivalent mutant.
|
|
414
|
-
const leaf = text.split(/\s+as\s+/)[0];
|
|
415
|
-
const trimmed = leaf.replace(/[;\s]+$/, '').replace(/^::/, '');
|
|
416
|
-
return trimmed ? [trimmed] : [];
|
|
417
|
-
}
|
|
418
|
-
const close = matchingClose(text, open, '{', '}');
|
|
419
|
-
const prefix = text.slice(0, open).replace(/::\s*$/, '').trim();
|
|
420
|
-
const inner = text.slice(open + 1, close);
|
|
421
|
-
const parts = [];
|
|
422
|
-
let depth = 0;
|
|
423
|
-
let current = '';
|
|
424
|
-
for (const char of inner) {
|
|
425
|
-
if (char === '{')
|
|
426
|
-
depth += 1;
|
|
427
|
-
if (char === '}')
|
|
428
|
-
depth -= 1;
|
|
429
|
-
if (char === ',' && depth === 0) {
|
|
430
|
-
parts.push(current);
|
|
431
|
-
current = '';
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
current += char;
|
|
435
|
-
}
|
|
436
|
-
parts.push(current);
|
|
437
|
-
const results = [];
|
|
438
|
-
for (const part of parts) {
|
|
439
|
-
const expanded = expandUsePath(part);
|
|
440
|
-
if (expanded === null)
|
|
484
|
+
function readUseTree(text, from) {
|
|
485
|
+
let index = from;
|
|
486
|
+
const take = (accept) => {
|
|
487
|
+
const token = rustToken(text, index);
|
|
488
|
+
if (token === null || !accept(token))
|
|
441
489
|
return null;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
490
|
+
index = token.end;
|
|
491
|
+
return token;
|
|
492
|
+
};
|
|
493
|
+
// No name is written as a mark is.
|
|
494
|
+
const mark = (text) => take((token) => token.text === text) !== null;
|
|
495
|
+
// A name, or `$crate` or another name of a macro's, which rustc reads
|
|
496
|
+
// where the macro is expanded.
|
|
497
|
+
const named = (token) => token.name || token.text.startsWith('$');
|
|
498
|
+
// `_` is a token of its own, and stands in a path only after `as`. A
|
|
499
|
+
// segment is never the keyword a declaration starts with, so that a
|
|
500
|
+
// declaration left open is never read on into the next.
|
|
501
|
+
const segment = () => take((token) => named(token) && (token.raw || !RUST_NOT_SEGMENT.has(token.text)))?.text ?? null;
|
|
502
|
+
const paths = [];
|
|
503
|
+
const tree = (head, depth) => {
|
|
504
|
+
if (depth > MAX_EXPANSION)
|
|
505
|
+
return 'overflow';
|
|
506
|
+
const path = [...head];
|
|
507
|
+
mark('::');
|
|
508
|
+
for (let name = segment(); name !== null; name = segment()) {
|
|
509
|
+
path.push(name);
|
|
510
|
+
if (mark('::'))
|
|
511
|
+
continue;
|
|
512
|
+
if (take((token) => !token.raw && token.text === 'as') !== null && take(named) === null)
|
|
513
|
+
return 'refused';
|
|
514
|
+
paths.push(path);
|
|
515
|
+
return 'read';
|
|
516
|
+
}
|
|
517
|
+
// A separator was the last token read, or nothing was.
|
|
518
|
+
if (mark('*')) {
|
|
519
|
+
paths.push([...path, '*']);
|
|
520
|
+
return 'read';
|
|
521
|
+
}
|
|
522
|
+
if (!mark('{'))
|
|
523
|
+
return 'refused';
|
|
524
|
+
if (mark('}')) {
|
|
525
|
+
// An empty group imports nothing, and rustc still reads the path before
|
|
526
|
+
// it as an import: its lint names `use a::{};` an unused one.
|
|
527
|
+
paths.push(path);
|
|
528
|
+
return 'read';
|
|
447
529
|
}
|
|
530
|
+
do {
|
|
531
|
+
const read = tree(path, depth + 1);
|
|
532
|
+
if (read !== 'read')
|
|
533
|
+
return read;
|
|
534
|
+
if (paths.length > MAX_EXPANSION)
|
|
535
|
+
return 'overflow';
|
|
536
|
+
} while (mark(',') && rustToken(text, index)?.text !== '}');
|
|
537
|
+
return mark('}') ? 'read' : 'refused';
|
|
538
|
+
};
|
|
539
|
+
const read = tree([], 0);
|
|
540
|
+
return { paths: read === 'read' ? paths : read, end: index };
|
|
541
|
+
}
|
|
542
|
+
/** The module a path of a use tree names, in the notation a rule writes, or nothing. */
|
|
543
|
+
function usePath(segments) {
|
|
544
|
+
// `a::b::self` names `a::b`, as `a::{self}` names `a`. A glob of nothing,
|
|
545
|
+
// the one path a `*` starts, names no module.
|
|
546
|
+
const named = segments.length > 1 && segments.at(-1) === 'self' ? segments.slice(0, -1) : segments;
|
|
547
|
+
return named[0] === '*' ? '' : named.join('::');
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Expands one use tree, with its `;` or without, into the module paths it
|
|
551
|
+
* names.
|
|
552
|
+
*
|
|
553
|
+
* `a::{b, c::{d, e}}` is three dependencies written as one declaration, and a
|
|
554
|
+
* rule about `a::c` has to see them. A tree is read as rustc reads it: white
|
|
555
|
+
* space and comments between its tokens, a raw identifier without its `r#`, a
|
|
556
|
+
* name in its NFC form, and `self` at the end of a path naming what is before
|
|
557
|
+
* it. Text that is no use tree, which rustc refuses, names nothing (ADR-0008,
|
|
558
|
+
* amended 2026-10-18). Returns null when the tree would name more than
|
|
559
|
+
* MAX_EXPANSION paths, or nest its groups deeper than that, which the caller
|
|
560
|
+
* reports rather than truncating.
|
|
561
|
+
*/
|
|
562
|
+
export function expandUsePath(input) {
|
|
563
|
+
const { paths, end } = readUseTree(input, 0);
|
|
564
|
+
if (paths === 'overflow')
|
|
565
|
+
return null;
|
|
566
|
+
const semicolon = rustToken(input, end);
|
|
567
|
+
const rest = input.slice(semicolon?.text === ';' ? semicolon.end : end);
|
|
568
|
+
return paths !== 'refused' && RUST_BLANK.test(rest) ? paths.map(usePath).filter((path) => path !== '') : [];
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The crate an extern crate declaration names, read from after `crate`, or
|
|
572
|
+
* null where rustc refuses it: `extern crate name (as alias)? ;`, the name an
|
|
573
|
+
* identifier or `self`.
|
|
574
|
+
*/
|
|
575
|
+
function externCrate(text, from) {
|
|
576
|
+
const crate = rustToken(text, from);
|
|
577
|
+
if (crate === null || !crate.name || crate.text === '_')
|
|
578
|
+
return null;
|
|
579
|
+
let next = rustToken(text, crate.end);
|
|
580
|
+
if (next !== null && !next.raw && next.text === 'as') {
|
|
581
|
+
const alias = rustToken(text, next.end);
|
|
582
|
+
next = alias !== null && alias.name ? rustToken(text, alias.end) : null;
|
|
448
583
|
}
|
|
449
|
-
return
|
|
584
|
+
return next?.text === ';' ? crate.text : null;
|
|
450
585
|
}
|
|
451
586
|
function readRust(collector) {
|
|
452
587
|
const { code } = collector.masked;
|
|
@@ -454,35 +589,206 @@ function readRust(collector) {
|
|
|
454
589
|
let match;
|
|
455
590
|
while ((match = RUST_USE.exec(code)) !== null) {
|
|
456
591
|
const start = match.index + match[0].length;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
592
|
+
if (match[0] !== 'use') {
|
|
593
|
+
const crate = externCrate(code, start);
|
|
594
|
+
if (crate !== null)
|
|
595
|
+
emit(collector, crate, 'use', match.index);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const { paths, end } = readUseTree(code, start);
|
|
599
|
+
if (paths === 'overflow') {
|
|
600
|
+
note(collector, 'truncated', match.index, `a use declaration expands to more than ${MAX_EXPANSION} module paths, or nests its groups deeper than that`);
|
|
601
|
+
}
|
|
602
|
+
else if (paths !== 'refused' && rustToken(code, end)?.text === ';') {
|
|
603
|
+
for (const path of paths)
|
|
604
|
+
emit(collector, usePath(path), 'use', match.index);
|
|
605
|
+
}
|
|
606
|
+
// On from where reading stopped, past the tree or at the token rustc
|
|
607
|
+
// would refuse, so that no token is read twice. No segment is `use` or
|
|
608
|
+
// `extern`, so no declaration is read into the next.
|
|
609
|
+
RUST_USE.lastIndex = end;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/* -------------------------------------------------------------------- c-sharp */
|
|
613
|
+
/** What C# lets continue an identifier beside a letter: a mark, a digit, a connector, a formatting character. */
|
|
614
|
+
const CSHARP_PART = String.raw `\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\p{Cf}`;
|
|
615
|
+
/** C#'s white space, and what ends a line. */
|
|
616
|
+
const CSHARP_BLANK = String.raw `\p{Zs}\t\v\f`;
|
|
617
|
+
const CSHARP_BREAK = String.raw `\r\n\u{85}\u{2028}\u{2029}`;
|
|
618
|
+
/**
|
|
619
|
+
* Everything the C# reader stops at: a using, a namespace, or a brace. Each
|
|
620
|
+
* keyword is a whole word, so neither the identifier `@using` nor a word an
|
|
621
|
+
* escape goes on with.
|
|
622
|
+
*/
|
|
623
|
+
const CSHARP_TOKEN = new RegExp(String.raw `(?<![${CSHARP_PART}@\\])(?:using|namespace)(?![${CSHARP_PART}\\])|[{}]`, 'gu');
|
|
624
|
+
/**
|
|
625
|
+
* A pre-processing directive, which takes the whole of its line, so that the
|
|
626
|
+
* message of a `#region` is no code.
|
|
627
|
+
*/
|
|
628
|
+
const CSHARP_DIRECTIVE = new RegExp(String.raw `^[${CSHARP_BLANK}]*#[^${CSHARP_BREAK}]*`, 'gmu');
|
|
629
|
+
/**
|
|
630
|
+
* One token of a using directive or a namespace declaration, after any white
|
|
631
|
+
* space or end of a line, which a masked comment is too: a word, as an
|
|
632
|
+
* identifier and a keyword are both written, or a mark.
|
|
633
|
+
*/
|
|
634
|
+
const CSHARP_WORD = new RegExp(String.raw `[${CSHARP_BLANK}${CSHARP_BREAK}]*(?:(@?(?:[${CSHARP_PART}]|\\u[\dA-Fa-f]{4}|\\U[\dA-Fa-f]{8})+)|(::|[.;=<>,?*\[\](){}]))`, 'uy');
|
|
635
|
+
const CSHARP_ESCAPE = /\\u([\dA-Fa-f]{4})|\\U([\dA-Fa-f]{8})/g;
|
|
636
|
+
const CSHARP_IDENTIFIER = new RegExp(String.raw `^[\p{L}\p{Nl}_][${CSHARP_PART}]*$`, 'u');
|
|
637
|
+
/**
|
|
638
|
+
* The identifier a word is, as C# compares two: with no `@`, each escape the
|
|
639
|
+
* character it stands for, and no formatting character. Null for a word that
|
|
640
|
+
* is no identifier: a digit first, or an escape of what no identifier holds.
|
|
641
|
+
*/
|
|
642
|
+
function csharpIdentifier(word) {
|
|
643
|
+
// A word holds an `@` only as its first character.
|
|
644
|
+
const decoded = word.replace('@', '').replace(CSHARP_ESCAPE, (_, short, long) => {
|
|
645
|
+
try {
|
|
646
|
+
return String.fromCodePoint(Number.parseInt(short ?? long, 16));
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// \U can write a number no character has. A dot is no part of a name.
|
|
650
|
+
return '.';
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
return CSHARP_IDENTIFIER.test(decoded) ? decoded.replace(/\p{Cf}/gu, '') : null;
|
|
654
|
+
}
|
|
655
|
+
/** What a type argument list holds beside names and angle brackets: no pointer, which no type argument is. */
|
|
656
|
+
const TYPE_MARKS = ['.', ',', '?', '[', ']', '(', ')', '::'];
|
|
657
|
+
/** A cursor over the tokens of one directive or declaration. */
|
|
658
|
+
class CsharpTokens {
|
|
659
|
+
text;
|
|
660
|
+
index;
|
|
661
|
+
constructor(text, index) {
|
|
662
|
+
this.text = text;
|
|
663
|
+
this.index = index;
|
|
664
|
+
}
|
|
665
|
+
next() {
|
|
666
|
+
CSHARP_WORD.lastIndex = this.index;
|
|
667
|
+
const match = CSHARP_WORD.exec(this.text);
|
|
668
|
+
return match === null ? null : { word: match[1], mark: match[2], end: CSHARP_WORD.lastIndex };
|
|
669
|
+
}
|
|
670
|
+
mark(mark) {
|
|
671
|
+
const token = this.next();
|
|
672
|
+
if (token?.mark !== mark)
|
|
673
|
+
return false;
|
|
674
|
+
this.index = token.end;
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
/** A keyword, as it is written: `@static`, and `static` with an escape in it, are identifiers. */
|
|
678
|
+
keyword(word) {
|
|
679
|
+
const token = this.next();
|
|
680
|
+
if (token?.word !== word)
|
|
681
|
+
return false;
|
|
682
|
+
this.index = token.end;
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* An identifier, which is never the keyword `using` or `namespace`: so a
|
|
687
|
+
* directive left open is never read on into the next.
|
|
688
|
+
*/
|
|
689
|
+
identifier() {
|
|
690
|
+
const token = this.next();
|
|
691
|
+
const name = token?.word === undefined || token.word === 'using' || token.word === 'namespace' ? null : csharpIdentifier(token.word);
|
|
692
|
+
if (name !== null)
|
|
693
|
+
this.index = token.end;
|
|
694
|
+
return name;
|
|
695
|
+
}
|
|
696
|
+
/** `name (. name)*`, the name of a namespace declaration. */
|
|
697
|
+
qualified() {
|
|
698
|
+
const parts = [];
|
|
699
|
+
for (let part = this.identifier(); part !== null; part = this.identifier()) {
|
|
700
|
+
parts.push(part);
|
|
701
|
+
if (!this.mark('.'))
|
|
702
|
+
return parts.join('.');
|
|
703
|
+
}
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* A namespace or type name, `(alias ::)? name (. name)*`, or null. What
|
|
708
|
+
* stands before `::` says where to look the name up, the global namespace
|
|
709
|
+
* or an extern alias, and is no part of it. Where `generic` allows them, a
|
|
710
|
+
* type argument list after a name is passed over.
|
|
711
|
+
*/
|
|
712
|
+
name(generic) {
|
|
713
|
+
let part = this.identifier();
|
|
714
|
+
if (part !== null && this.mark('::'))
|
|
715
|
+
part = this.identifier();
|
|
716
|
+
const parts = [];
|
|
717
|
+
for (; part !== null; part = this.identifier()) {
|
|
718
|
+
parts.push(part);
|
|
719
|
+
if (generic && !this.typeArguments())
|
|
720
|
+
return null;
|
|
721
|
+
if (!this.mark('.'))
|
|
722
|
+
return parts.join('.');
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Passes over a type argument list, `<` to the `>` that closes it, and is
|
|
728
|
+
* false where something no type holds comes first. Token by token, so it
|
|
729
|
+
* never reads past a `;`, a brace or the next directive.
|
|
730
|
+
*/
|
|
731
|
+
typeArguments() {
|
|
732
|
+
if (!this.mark('<'))
|
|
733
|
+
return true;
|
|
734
|
+
for (let depth = 1; depth > 0;) {
|
|
735
|
+
if (this.mark('<'))
|
|
462
736
|
depth += 1;
|
|
463
|
-
else if (
|
|
464
|
-
if (depth === 0)
|
|
465
|
-
break;
|
|
737
|
+
else if (this.mark('>'))
|
|
466
738
|
depth -= 1;
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
739
|
+
else if (this.identifier() === null && !TYPE_MARKS.some((mark) => this.mark(mark)))
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
/** The array, nullable and pointer marks after a type, `[]`, `[,]`, `?` and `*`, and false for `[` left open. */
|
|
745
|
+
suffixes() {
|
|
746
|
+
for (;;) {
|
|
747
|
+
if (this.mark('?') || this.mark('*'))
|
|
748
|
+
continue;
|
|
749
|
+
if (!this.mark('['))
|
|
750
|
+
return true;
|
|
751
|
+
while (this.mark(','))
|
|
752
|
+
continue;
|
|
753
|
+
if (!this.mark(']'))
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* The namespace or type a using directive names, read from after `using`,
|
|
760
|
+
* or null where C# would refuse it:
|
|
761
|
+
*
|
|
762
|
+
* using static unsafe? type ; using unsafe? alias = name-or-type ; using namespace ;
|
|
763
|
+
*
|
|
764
|
+
* `global` before `using` changes nothing of it. A using statement and a
|
|
765
|
+
* using declaration are none of these, and are read as nothing.
|
|
766
|
+
*/
|
|
767
|
+
function usingDirective(tokens) {
|
|
768
|
+
let target;
|
|
769
|
+
if (tokens.keyword('static')) {
|
|
770
|
+
tokens.keyword('unsafe');
|
|
771
|
+
target = tokens.name(true);
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
const unsafe = tokens.keyword('unsafe');
|
|
775
|
+
const start = tokens.index;
|
|
776
|
+
if (tokens.identifier() !== null && tokens.mark('=')) {
|
|
777
|
+
target = tokens.name(true);
|
|
778
|
+
// An alias may name an array, a nullable or a pointer of a type.
|
|
779
|
+
if (!tokens.suffixes())
|
|
780
|
+
return null;
|
|
471
781
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
note(collector, 'truncated', match.index, `a use declaration expands to more than ${MAX_EXPANSION} module paths`);
|
|
782
|
+
else if (unsafe) {
|
|
783
|
+
return null;
|
|
475
784
|
}
|
|
476
785
|
else {
|
|
477
|
-
|
|
478
|
-
|
|
786
|
+
tokens.index = start;
|
|
787
|
+
target = tokens.name(false);
|
|
479
788
|
}
|
|
480
|
-
RUST_USE.lastIndex = end;
|
|
481
789
|
}
|
|
790
|
+
return tokens.mark(';') ? target : null;
|
|
482
791
|
}
|
|
483
|
-
/* -------------------------------------------------------------------- c-sharp */
|
|
484
|
-
/** Everything the C# reader stops at: a using, a namespace, or a brace. */
|
|
485
|
-
const CSHARP_TOKEN = /\b(?:using|namespace)\b|[{}]/g;
|
|
486
792
|
/**
|
|
487
793
|
* Reads using directives and the namespaces a file declares, in one pass.
|
|
488
794
|
*
|
|
@@ -496,10 +802,17 @@ const CSHARP_TOKEN = /\b(?:using|namespace)\b|[{}]/g;
|
|
|
496
802
|
* the brace depth falls below its body's, so nothing here compares an offset
|
|
497
803
|
* with the offset of a delimiter - which no keyword can ever sit on, and which
|
|
498
804
|
* would leave `<` and `<=` meaning the same thing to every test. The code is
|
|
499
|
-
* masked, so no brace in a string or a
|
|
805
|
+
* masked, so no brace in a string, a comment or a pre-processing directive
|
|
806
|
+
* counts.
|
|
807
|
+
*
|
|
808
|
+
* A directive and a declaration are read token by token, as the C#
|
|
809
|
+
* specification has them (ADR-0008, amended 2026-10-18): white space and
|
|
810
|
+
* comments between the tokens, an identifier as C# compares two, and what
|
|
811
|
+
* C# refuses read as nothing.
|
|
500
812
|
*/
|
|
501
813
|
function readCsharp(collector) {
|
|
502
|
-
|
|
814
|
+
// A byte order mark at the head of a file is no character of its text.
|
|
815
|
+
const code = collector.masked.code.replace(/^\u{feff}/u, ' ').replace(CSHARP_DIRECTIVE, (line) => ' '.repeat(line.length));
|
|
503
816
|
const declared = new Set();
|
|
504
817
|
const open = [];
|
|
505
818
|
let fileScoped;
|
|
@@ -518,52 +831,33 @@ function readCsharp(collector) {
|
|
|
518
831
|
open.pop();
|
|
519
832
|
continue;
|
|
520
833
|
}
|
|
521
|
-
const
|
|
522
|
-
reader.index = match.index + token.length;
|
|
523
|
-
reader.skipSpace();
|
|
834
|
+
const tokens = new CsharpTokens(code, CSHARP_TOKEN.lastIndex);
|
|
524
835
|
if (token === 'namespace') {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
declared.add(full);
|
|
542
|
-
continue;
|
|
836
|
+
const name = tokens.qualified();
|
|
837
|
+
if (name !== null) {
|
|
838
|
+
const scoped = tokens.mark(';');
|
|
839
|
+
if (scoped || tokens.mark('{')) {
|
|
840
|
+
const outer = open.at(-1);
|
|
841
|
+
const full = outer === undefined ? name : `${outer.name}.${name}`;
|
|
842
|
+
// A file-scoped namespace covers the rest of the file, and C# allows
|
|
843
|
+
// neither a second one nor a block beside it. A block's brace is
|
|
844
|
+
// read here, and counted.
|
|
845
|
+
if (scoped)
|
|
846
|
+
fileScoped = full;
|
|
847
|
+
else
|
|
848
|
+
open.push({ name: full, depth: (depth += 1) });
|
|
849
|
+
declared.add(full);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
543
852
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// `using Alias = System.Text.Json;` - the dependency is the right-hand side.
|
|
549
|
-
if (reader.eat('=')) {
|
|
550
|
-
reader.skipSpace();
|
|
551
|
-
target = reader.dotted();
|
|
552
|
-
reader.skipSpace();
|
|
553
|
-
reader.skipGenerics();
|
|
554
|
-
reader.skipSpace();
|
|
853
|
+
else {
|
|
854
|
+
const target = usingDirective(tokens);
|
|
855
|
+
if (target !== null)
|
|
856
|
+
emit(collector, target, 'using', match.index, open.at(-1)?.name ?? fileScoped);
|
|
555
857
|
}
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
// where a terminator has to be. Earlier versions guarded those two shapes
|
|
560
|
-
// explicitly, and a third time against an empty name - none of the three
|
|
561
|
-
// changed an outcome, which mutation testing established by leaving the
|
|
562
|
-
// tests green with each of them defeated. `emit` refuses an empty
|
|
563
|
-
// specifier, so this line is the only check that has to be right.
|
|
564
|
-
if (reader.peek() !== ';')
|
|
565
|
-
continue;
|
|
566
|
-
emit(collector, target, 'using', match.index, open.at(-1)?.name ?? fileScoped);
|
|
858
|
+
// On from where reading stopped, so that no token is read twice. No name
|
|
859
|
+
// is `using` or `namespace`, so no directive is read into the next.
|
|
860
|
+
CSHARP_TOKEN.lastIndex = tokens.index;
|
|
567
861
|
}
|
|
568
862
|
collector.namespaces = [...declared];
|
|
569
863
|
}
|