@descent-vtt/spec-guard 0.27.0 → 0.28.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  3. package/dist/cites.d.ts.map +1 -1
  4. package/dist/cites.js +11 -1
  5. package/dist/cites.js.map +1 -1
  6. package/dist/cli.d.ts +15 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +25 -6
  9. package/dist/cli.js.map +1 -1
  10. package/dist/engine.d.ts +48 -1
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +224 -13
  13. package/dist/engine.js.map +1 -1
  14. package/dist/glob.d.ts +9 -0
  15. package/dist/glob.d.ts.map +1 -1
  16. package/dist/glob.js +23 -0
  17. package/dist/glob.js.map +1 -1
  18. package/dist/impact.d.ts +13 -9
  19. package/dist/impact.d.ts.map +1 -1
  20. package/dist/impact.js +5 -3
  21. package/dist/impact.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/polyglot.d.ts +4 -1
  25. package/dist/polyglot.d.ts.map +1 -1
  26. package/dist/polyglot.js +448 -69
  27. package/dist/polyglot.js.map +1 -1
  28. package/dist/prove.d.ts.map +1 -1
  29. package/dist/prove.js +42 -14
  30. package/dist/prove.js.map +1 -1
  31. package/dist/reporter.d.ts.map +1 -1
  32. package/dist/reporter.js +13 -0
  33. package/dist/reporter.js.map +1 -1
  34. package/dist/runner.d.ts.map +1 -1
  35. package/dist/runner.js +17 -13
  36. package/dist/runner.js.map +1 -1
  37. package/dist/scope.d.ts +22 -2
  38. package/dist/scope.d.ts.map +1 -1
  39. package/dist/scope.js +10 -2
  40. package/dist/scope.js.map +1 -1
  41. package/dist/structure.d.ts +3 -1
  42. package/dist/structure.d.ts.map +1 -1
  43. package/dist/structure.js +15 -4
  44. package/dist/structure.js.map +1 -1
  45. package/dist/types.d.ts +7 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +1 -1
package/dist/polyglot.js CHANGED
@@ -81,7 +81,7 @@ function mask(source, language) {
81
81
  // The opening delimiter is left visible so a reader can see that a literal
82
82
  // starts here; everything inside it is blanked so no keyword can hide there.
83
83
  const blanks = [...comments, ...strings.map(([start, end]) => [start + 1, end])];
84
- return { code: maskRanges(source, blanks), strings, source, starts: lineStarts(source), unterminated };
84
+ return { code: maskRanges(source, blanks), strings, comments, source, starts: lineStarts(source), unterminated };
85
85
  }
86
86
  /** The literal that starts exactly at `offset`, or null. */
87
87
  function literalAt(masked, offset) {
@@ -132,7 +132,18 @@ export function literalValue(raw) {
132
132
  return text;
133
133
  }
134
134
  /* ------------------------------------------------------------------ reading */
135
- const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
135
+ /**
136
+ * A Go identifier: a letter, then letters and decimal digits, where a letter
137
+ * is `_` or a character of a Letter category, Lu, Ll, Lt, Lm or Lo, and a
138
+ * digit one of Nd (The Go Programming Language Specification, "Characters",
139
+ * "Letters and digits" and "Identifiers", go1.27, read on 2026-10-19). The
140
+ * page has those categories as Unicode 8.0 gives them; it follows from it,
141
+ * and is not on it, that a later Unicode's letters, which the Node that runs
142
+ * spec-guard has, are read as letters. A mark is no letter, so `cafe` with a
143
+ * combining accent is no identifier, and the text is not normalised: `café`
144
+ * written so is two characters of which the second is none.
145
+ */
146
+ const IDENTIFIER = /[\p{L}_][\p{L}\p{Nd}_]*/uy;
136
147
  /**
137
148
  * Whitespace-skipping cursor over masked code, which a Go import and a Python
138
149
  * dynamic import are read with.
@@ -412,6 +423,11 @@ function readPython(collector) {
412
423
  }
413
424
  /* ----------------------------------------------------------------------- go */
414
425
  const GO_IMPORT = /\bimport\b/g;
426
+ /** Go's keywords, which no identifier is, so no package name ("Keywords"). */
427
+ const GO_KEYWORDS = new Set([
428
+ ...['break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for', 'func', 'go', 'goto'],
429
+ ...['if', 'import', 'interface', 'map', 'package', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var'],
430
+ ]);
415
431
  function readGo(collector) {
416
432
  const { code } = collector.masked;
417
433
  GO_IMPORT.lastIndex = 0;
@@ -428,11 +444,29 @@ function readGo(collector) {
428
444
  GO_IMPORT.lastIndex = close;
429
445
  continue;
430
446
  }
431
- // A single import may carry an alias: `import fmt2 "fmt"`, `import _ "x"`.
432
- if (reader.peek() === '_' || reader.peek() === '.')
447
+ // A single import may carry a package name, `import fmt2 "fmt"`, `import
448
+ // 名 "fmt"` or `import _ "x"`, or a `.` ("Import declarations"). A name at
449
+ // the end of its line has a semicolon put after it, and a comment that
450
+ // holds a line break ends the line too ("Semicolons", "Comments"): the
451
+ // import ends there, with no path, which Go refuses.
452
+ if (reader.peek() === '.') {
433
453
  reader.index += 1;
434
- else
435
- reader.identifier();
454
+ }
455
+ else {
456
+ const name = reader.identifier();
457
+ // On from the keyword, so that `import import "x"` is read as no
458
+ // import, and not as a second.
459
+ if (GO_KEYWORDS.has(name)) {
460
+ GO_IMPORT.lastIndex = reader.index;
461
+ continue;
462
+ }
463
+ // With no name the space after `import` was passed over already, so
464
+ // this finds a line break only after one.
465
+ const after = reader.index;
466
+ reader.skipSpace();
467
+ if (code.slice(after, reader.index).includes('\n'))
468
+ continue;
469
+ }
436
470
  reader.skipSpace();
437
471
  const literal = literalAt(collector.masked, reader.index);
438
472
  if (literal !== null)
@@ -457,8 +491,26 @@ const RUST_TOKEN = new RegExp(String.raw `[${RUST_SPACE}]*(?:(r#)?([\p{XID_Start
457
491
  const RUST_BLANK = new RegExp(String.raw `^[${RUST_SPACE}]*$`, 'u');
458
492
  /** What `r#` cannot make a raw identifier of. */
459
493
  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']);
494
+ /**
495
+ * What no segment of a path, alias or crate name is unless written raw:
496
+ * `_`, and Rust's keywords in every edition, strict and reserved (Keywords,
497
+ * as read on 2026-10-19), `Self` among them, which no use path holds
498
+ * (Use declarations, "use Paths"). So a declaration left open is never read
499
+ * on into the next, whose `use` or `extern` is one. `crate`, `self` and
500
+ * `super` are not here: they stand where `rustPathRefused` lets them.
501
+ * `async`, `await` and `dyn` are keywords from the 2018 edition on, `try`
502
+ * from 2018 and `gen` from 2024, and the edition is in `Cargo.toml`, which
503
+ * the reader does not open, so each is read as the name it is before them.
504
+ */
505
+ const RUST_NOT_SEGMENT = new Set([
506
+ '_',
507
+ ...['as', 'break', 'const', 'continue', 'else', 'enum', 'extern', 'false', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop'],
508
+ ...['match', 'mod', 'move', 'mut', 'pub', 'ref', 'return', 'Self', 'static', 'struct', 'trait', 'true', 'type', 'unsafe'],
509
+ ...['use', 'where', 'while', 'abstract', 'become', 'box', 'do', 'final', 'macro', 'override', 'priv', 'typeof', 'unsized'],
510
+ ...['virtual', 'yield'],
511
+ ]);
512
+ /** The words a path starts with to name a module, which an import binds only under a name of its own. */
513
+ const RUST_PATH_KEYWORDS = new Set(['crate', 'self', 'super', '$crate']);
462
514
  /** The token at `index`, or null where none starts. */
463
515
  function rustToken(text, index) {
464
516
  RUST_TOKEN.lastIndex = index;
@@ -471,6 +523,36 @@ function rustToken(text, index) {
471
523
  return null;
472
524
  return { text: word?.normalize('NFC') ?? mark, name: word !== undefined, raw: raw !== undefined, end: RUST_TOKEN.lastIndex };
473
525
  }
526
+ /**
527
+ * Whether rustc refuses a path of a use tree for where `crate`, `self`,
528
+ * `super` or `$crate` stands in it (Paths, "Path qualifiers"), or for
529
+ * importing a module that it binds only under a name given with `as` (Use
530
+ * declarations, "Restrictions"), each as read on 2026-10-19, and each held
531
+ * to rustc 1.97.1 in every edition.
532
+ */
533
+ function rustPathRefused({ segments, global, bare }) {
534
+ const last = segments.length - 1;
535
+ const misplaced = segments.some((segment, at) => {
536
+ // First, and not after a `::`.
537
+ if (segment === 'crate' || segment === '$crate')
538
+ return at > 0 || global;
539
+ // First and not after a `::`, or last after one: `a::self` names `a`.
540
+ // `::{self as root}` imports the crate root in the 2015 edition and is
541
+ // refused from 2018, which the reader cannot tell apart, so it is read.
542
+ if (segment === 'self')
543
+ return at < last && (at > 0 || global);
544
+ // Only after other `super`s, or after a first `self`: a `self` anywhere
545
+ // else before it is refused where it stands.
546
+ if (segment === 'super')
547
+ return global || !segments.slice(0, at).every((before) => before === 'super' || before === 'self');
548
+ return false;
549
+ });
550
+ // `use crate;`, `use self;`, `use super::super;` and `use a::{self}` with
551
+ // `a` one of them: the module is bound only under a name given with `as`.
552
+ // `use crate::{};` binds nothing, and rustc takes it. A trailing `self`
553
+ // is one of those words too, so `use crate::self;` is refused alike.
554
+ return misplaced || (bare && segments.every((segment) => RUST_PATH_KEYWORDS.has(segment)));
555
+ }
474
556
  /**
475
557
  * The paths one use tree names, segment by segment, read as the Rust
476
558
  * Reference's grammar has it:
@@ -495,28 +577,36 @@ function readUseTree(text, from) {
495
577
  // A name, or `$crate` or another name of a macro's, which rustc reads
496
578
  // where the macro is expanded.
497
579
  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.
580
+ // `_` is a token of its own, and stands in a path only after `as`.
501
581
  const segment = () => take((token) => named(token) && (token.raw || !RUST_NOT_SEGMENT.has(token.text)))?.text ?? null;
582
+ // A name given with `as`: `_`, or a name that is no keyword and no word a path starts with.
583
+ const alias = (token) => named(token) && (token.raw || token.text === '_' || !(RUST_NOT_SEGMENT.has(token.text) || RUST_PATH_KEYWORDS.has(token.text)));
502
584
  const paths = [];
503
- const tree = (head, depth) => {
585
+ const tree = (head, rooted, depth) => {
504
586
  if (depth > MAX_EXPANSION)
505
587
  return 'overflow';
506
588
  const path = [...head];
507
- mark('::');
589
+ // A `::` before the first segment of all is the global root, and rustc
590
+ // refuses one anywhere else: `use a::{::b};`.
591
+ const leading = mark('::');
592
+ if (leading && head.length > 0)
593
+ return 'refused';
594
+ const global = rooted || leading;
508
595
  for (let name = segment(); name !== null; name = segment()) {
509
596
  path.push(name);
510
597
  if (mark('::'))
511
598
  continue;
512
- if (take((token) => !token.raw && token.text === 'as') !== null && take(named) === null)
599
+ const renamed = take((token) => !token.raw && token.text === 'as') !== null;
600
+ if (renamed && take(alias) === null)
513
601
  return 'refused';
514
- paths.push(path);
602
+ paths.push({ segments: path, global, bare: !renamed });
515
603
  return 'read';
516
604
  }
517
605
  // A separator was the last token read, or nothing was.
518
606
  if (mark('*')) {
519
- paths.push([...path, '*']);
607
+ // A glob binds no module of its own; its `*` is no word a path starts
608
+ // with either, so `bare` is never what decides it.
609
+ paths.push({ segments: [...path, '*'], global, bare: false });
520
610
  return 'read';
521
611
  }
522
612
  if (!mark('{'))
@@ -524,11 +614,11 @@ function readUseTree(text, from) {
524
614
  if (mark('}')) {
525
615
  // An empty group imports nothing, and rustc still reads the path before
526
616
  // it as an import: its lint names `use a::{};` an unused one.
527
- paths.push(path);
617
+ paths.push({ segments: path, global, bare: false });
528
618
  return 'read';
529
619
  }
530
620
  do {
531
- const read = tree(path, depth + 1);
621
+ const read = tree(path, global, depth + 1);
532
622
  if (read !== 'read')
533
623
  return read;
534
624
  if (paths.length > MAX_EXPANSION)
@@ -536,11 +626,11 @@ function readUseTree(text, from) {
536
626
  } while (mark(',') && rustToken(text, index)?.text !== '}');
537
627
  return mark('}') ? 'read' : 'refused';
538
628
  };
539
- const read = tree([], 0);
540
- return { paths: read === 'read' ? paths : read, end: index };
629
+ const read = tree([], false, 0);
630
+ return { paths: read !== 'read' ? read : paths.some(rustPathRefused) ? 'refused' : paths, end: index };
541
631
  }
542
632
  /** The module a path of a use tree names, in the notation a rule writes, or nothing. */
543
- function usePath(segments) {
633
+ function usePath({ segments }) {
544
634
  // `a::b::self` names `a::b`, as `a::{self}` names `a`. A glob of nothing,
545
635
  // the one path a `*` starts, names no module.
546
636
  const named = segments.length > 1 && segments.at(-1) === 'self' ? segments.slice(0, -1) : segments;
@@ -555,7 +645,10 @@ function usePath(segments) {
555
645
  * space and comments between its tokens, a raw identifier without its `r#`, a
556
646
  * name in its NFC form, and `self` at the end of a path naming what is before
557
647
  * 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
648
+ * amended 2026-10-18), and so does a tree with a keyword for a segment, or
649
+ * with `crate`, `self` or `super` where rustc refuses it (amended
650
+ * 2026-10-19). A `$` name is read as a macro's, as a tree alone says nothing
651
+ * of where it stands. Returns null when the tree would name more than
559
652
  * MAX_EXPANSION paths, or nest its groups deeper than that, which the caller
560
653
  * reports rather than truncating.
561
654
  */
@@ -570,38 +663,135 @@ export function expandUsePath(input) {
570
663
  /**
571
664
  * The crate an extern crate declaration names, read from after `crate`, or
572
665
  * null where rustc refuses it: `extern crate name (as alias)? ;`, the name an
573
- * identifier or `self`.
666
+ * identifier that is no keyword, or `self` with an alias, which rustc takes
667
+ * only so, and the end of its `;`.
574
668
  */
575
669
  function externCrate(text, from) {
670
+ // A name that is no keyword and no word a path starts with, raw or not.
671
+ const name = (token) => token.name && (token.raw || !(RUST_NOT_SEGMENT.has(token.text) || RUST_PATH_KEYWORDS.has(token.text)));
576
672
  const crate = rustToken(text, from);
577
- if (crate === null || !crate.name || crate.text === '_')
673
+ if (crate === null || !(name(crate) || crate.text === 'self'))
578
674
  return null;
579
675
  let next = rustToken(text, crate.end);
580
- if (next !== null && !next.raw && next.text === 'as') {
676
+ const renamed = next !== null && !next.raw && next.text === 'as';
677
+ if (renamed) {
581
678
  const alias = rustToken(text, next.end);
582
- next = alias !== null && alias.name ? rustToken(text, alias.end) : null;
679
+ next = alias !== null && (name(alias) || alias.text === '_') ? rustToken(text, alias.end) : null;
680
+ }
681
+ return next?.text === ';' && (renamed || crate.text !== 'self') ? { crate: crate.text, end: next.end } : null;
682
+ }
683
+ /**
684
+ * Where a macro's tokens stand: the group of an invocation, `m!(...)`,
685
+ * `path::m![...]` or `m! {...}`, of a definition, `macro_rules! m {...}`
686
+ * and `macro m(...) {...}`, and of an attribute, `#[...]` and `#![...]`.
687
+ * rustc reads them as tokens and refuses none of them for not being a
688
+ * declaration: a matcher holds the word `use`, and `quote!` writes
689
+ * `use #name::Thing;`. Too wide a reach costs a note, never a reading: a
690
+ * declaration in one is read as everywhere else.
691
+ */
692
+ const RUST_MACRO = new RegExp(String.raw `(?:(?<!\p{XID_Continue})macro_rules[${RUST_SPACE}]*![${RUST_SPACE}]*[\p{XID_Start}_]\p{XID_Continue}*|(?<![\p{XID_Continue}#])(macro)[${RUST_SPACE}]+[\p{XID_Start}_]\p{XID_Continue}*|\p{XID_Continue}!|#!?)[${RUST_SPACE}]*([(\[{])`,
693
+ // Emptied, the flags leave each `exec` in `macroRanges` reading from the
694
+ // start, and on a file with an attribute its loop pushes a range a turn
695
+ // for ever. The thread runs out of memory, and no test's timer fires
696
+ // inside the loop, so a sweep counts an error: the tests of a macro's
697
+ // tokens see the mutant where they run before any test that meets it.
698
+ 'gu');
699
+ const RUST_CLOSERS = { '(': ')', '[': ']', '{': '}' };
700
+ /** The brace a `macro` definition's body opens after its parameters. */
701
+ const RUST_BODY = new RegExp(String.raw `[${RUST_SPACE}]*\{`, 'uy');
702
+ /** The half-open ranges of a file's macro tokens, outermost only, ascending. */
703
+ function macroRanges(code) {
704
+ const ranges = [];
705
+ RUST_MACRO.lastIndex = 0;
706
+ let match;
707
+ while ((match = RUST_MACRO.exec(code)) !== null) {
708
+ const opener = match[2];
709
+ let end = matchingClose(code, RUST_MACRO.lastIndex - 1, opener, RUST_CLOSERS[opener]) + 1;
710
+ // The body ends at its `}`, and `- 1` for its `+ 1` is an equivalent
711
+ // mutant: no `use` starts in the body's last two characters.
712
+ RUST_BODY.lastIndex = end;
713
+ if (match[1] !== undefined && RUST_BODY.test(code))
714
+ end = matchingClose(code, RUST_BODY.lastIndex - 1, '{', '}') + 1;
715
+ ranges.push([match.index, end]);
716
+ // Outermost only: each is read to its end once, so nesting costs nothing.
717
+ RUST_MACRO.lastIndex = end;
583
718
  }
584
- return next?.text === ';' ? crate.text : null;
719
+ return ranges;
585
720
  }
721
+ const RUST_SPACE_CHARACTER = new RegExp(`[${RUST_SPACE}]`, 'u');
722
+ /** Whether `at` is where an item or a statement may start: at the head of the file, or after `;`, a brace, an attribute's `]`, `pub` or its `(...)`. */
723
+ function startsAnItem(code, at) {
724
+ let before = at;
725
+ while (RUST_SPACE_CHARACTER.test(code[before - 1] ?? ''))
726
+ before -= 1;
727
+ return before === 0 || ';{}])'.includes(code[before - 1]) || /(?<!\p{XID_Continue})pub$/u.test(code.slice(Math.max(0, before - 4), before));
728
+ }
729
+ /**
730
+ * A doc comment, which rustc reads as an attribute: three slashes and no
731
+ * fourth, `//!`, a block opened with two stars and no third that holds
732
+ * something, and a block opened with `/*!` (Comments, as read on
733
+ * 2026-10-19).
734
+ */
735
+ const RUST_DOC = /^(?:\/\/\/(?!\/)|\/\/!|\/\*\*(?![*/])|\/\*!)/;
586
736
  function readRust(collector) {
587
- const { code } = collector.masked;
737
+ const { code, comments, source } = collector.masked;
738
+ const macros = macroRanges(code);
739
+ let macro = 0;
740
+ let comment = 0;
588
741
  RUST_USE.lastIndex = 0;
589
742
  let match;
590
743
  while ((match = RUST_USE.exec(code)) !== null) {
591
- const start = match.index + match[0].length;
744
+ const at = match.index;
745
+ while (macro < macros.length && macros[macro][1] <= at)
746
+ macro += 1;
747
+ // A range starts at a macro's name or an attribute's `#`, never where a
748
+ // `use` does, so `<` for `<=` is an equivalent mutant; and no comment
749
+ // starts where a `use` does, so `<=` for the `<` after it is one too.
750
+ const inMacro = macro < macros.length && macros[macro][0] <= at;
751
+ while (comment < comments.length && comments[comment][0] < at)
752
+ comment += 1;
753
+ // A doc comment between `use` and the end of the declaration, which rustc
754
+ // reads as an attribute where no attribute may stand.
755
+ const documented = (end) => {
756
+ for (let next = comment; next < comments.length && comments[next][0] < end; next++) {
757
+ if (RUST_DOC.test(source.slice(comments[next][0], comments[next][0] + 4)))
758
+ return true;
759
+ }
760
+ return false;
761
+ };
762
+ // What rustc refuses is said where it starts as a declaration: where an
763
+ // item or a statement may start, and not among a macro's tokens, which
764
+ // rustc does not read as one (ADR-0008, amended 2026-10-19).
765
+ const refused = () => {
766
+ if (!inMacro && startsAnItem(code, at)) {
767
+ note(collector, 'refused', at, 'a declaration that starts as a use or an extern crate is one rustc refuses, and no module is read from it');
768
+ }
769
+ };
770
+ const start = at + match[0].length;
592
771
  if (match[0] !== 'use') {
593
- const crate = externCrate(code, start);
594
- if (crate !== null)
595
- emit(collector, crate, 'use', match.index);
772
+ const extern = externCrate(code, start);
773
+ if (extern !== null && !documented(extern.end))
774
+ emit(collector, extern.crate, 'use', at);
775
+ else
776
+ refused();
596
777
  continue;
597
778
  }
598
779
  const { paths, end } = readUseTree(code, start);
780
+ const semicolon = rustToken(code, end);
599
781
  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`);
782
+ note(collector, 'truncated', at, `a use declaration expands to more than ${MAX_EXPANSION} module paths, or nests its groups deeper than that`);
601
783
  }
602
- else if (paths !== 'refused' && rustToken(code, end)?.text === ';') {
784
+ else if (paths !== 'refused' &&
785
+ semicolon?.text === ';' &&
786
+ !documented(semicolon.end) &&
787
+ // A `$` name stands only in a macro's tokens, where it is read as the
788
+ // macro is expanded.
789
+ (inMacro || !paths.some(({ segments }) => segments.some((segment) => segment.startsWith('$'))))) {
603
790
  for (const path of paths)
604
- emit(collector, usePath(path), 'use', match.index);
791
+ emit(collector, usePath(path), 'use', at);
792
+ }
793
+ else {
794
+ refused();
605
795
  }
606
796
  // On from where reading stopped, past the tree or at the token rustc
607
797
  // would refuse, so that no token is read twice. No segment is `use` or
@@ -634,6 +824,26 @@ const CSHARP_DIRECTIVE = new RegExp(String.raw `^[${CSHARP_BLANK}]*#[^${CSHARP_B
634
824
  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
825
  const CSHARP_ESCAPE = /\\u([\dA-Fa-f]{4})|\\U([\dA-Fa-f]{8})/g;
636
826
  const CSHARP_IDENTIFIER = new RegExp(String.raw `^[\p{L}\p{Nl}_][${CSHARP_PART}]*$`, 'u');
827
+ /**
828
+ * C#'s keywords (6.4.4, as read on 2026-10-19), which no identifier is
829
+ * unless written with `@`, or with an escape in it, which no keyword holds.
830
+ * Its contextual keywords, `global`, `var` and `dynamic` among them, are
831
+ * identifiers where a name stands.
832
+ */
833
+ const CSHARP_KEYWORDS = new Set([
834
+ ...['abstract', 'as', 'base', 'bool', 'break', 'byte', 'case', 'catch', 'char', 'checked', 'class', 'const', 'continue'],
835
+ ...['decimal', 'default', 'delegate', 'do', 'double', 'else', 'enum', 'event', 'explicit', 'extern', 'false', 'finally'],
836
+ ...['fixed', 'float', 'for', 'foreach', 'goto', 'if', 'implicit', 'in', 'int', 'interface', 'internal', 'is', 'lock'],
837
+ ...['long', 'namespace', 'new', 'null', 'object', 'operator', 'out', 'override', 'params', 'private', 'protected'],
838
+ ...['public', 'readonly', 'ref', 'return', 'sbyte', 'sealed', 'short', 'sizeof', 'stackalloc', 'static', 'string'],
839
+ ...['struct', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'uint', 'ulong', 'unchecked', 'unsafe', 'ushort'],
840
+ ...['using', 'virtual', 'void', 'volatile', 'while'],
841
+ ]);
842
+ /** The keywords that name a type (8.2.1, 8.3.1): `void` is one only before a `*`. */
843
+ const CSHARP_TYPE_KEYWORDS = new Set([
844
+ ...['bool', 'byte', 'char', 'decimal', 'double', 'float', 'int', 'long', 'object', 'sbyte', 'short', 'string', 'uint'],
845
+ ...['ulong', 'ushort'],
846
+ ]);
637
847
  /**
638
848
  * The identifier a word is, as C# compares two: with no `@`, each escape the
639
849
  * character it stands for, and no formatting character. Null for a word that
@@ -652,12 +862,16 @@ function csharpIdentifier(word) {
652
862
  });
653
863
  return CSHARP_IDENTIFIER.test(decoded) ? decoded.replace(/\p{Cf}/gu, '') : null;
654
864
  }
655
- /** What a type argument list holds beside names and angle brackets: no pointer, which no type argument is. */
656
- const TYPE_MARKS = ['.', ',', '?', '[', ']', '(', ')', '::'];
657
865
  /** A cursor over the tokens of one directive or declaration. */
658
866
  class CsharpTokens {
659
867
  text;
660
868
  index;
869
+ /** How deep the type being read is. */
870
+ nesting = 0;
871
+ /** Whether a type nested deeper than MAX_EXPANSION, and was not read. */
872
+ overflow = false;
873
+ /** The name of each type read, in the order written: a type is its name, then its arguments. */
874
+ names = [];
661
875
  constructor(text, index) {
662
876
  this.text = text;
663
877
  this.index = index;
@@ -683,12 +897,13 @@ class CsharpTokens {
683
897
  return true;
684
898
  }
685
899
  /**
686
- * An identifier, which is never the keyword `using` or `namespace`: so a
687
- * directive left open is never read on into the next.
900
+ * An identifier, which is never a keyword as it is written: so `using
901
+ * class.N;` names nothing, and a directive left open is never read on
902
+ * into the next, whose `using` or `namespace` is one.
688
903
  */
689
904
  identifier() {
690
905
  const token = this.next();
691
- const name = token?.word === undefined || token.word === 'using' || token.word === 'namespace' ? null : csharpIdentifier(token.word);
906
+ const name = token?.word === undefined || CSHARP_KEYWORDS.has(token.word) ? null : csharpIdentifier(token.word);
692
907
  if (name !== null)
693
908
  this.index = token.end;
694
909
  return name;
@@ -724,70 +939,213 @@ class CsharpTokens {
724
939
  return null;
725
940
  }
726
941
  /**
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.
942
+ * Passes over a type argument list, `<` to the `>` that closes it, each
943
+ * argument a type and none a pointer, which the compiler refuses there;
944
+ * false where something else stands. Token by token, so it never reads
945
+ * past a `;`, a brace or the next directive.
730
946
  */
731
947
  typeArguments() {
732
948
  if (!this.mark('<'))
733
949
  return true;
734
- for (let depth = 1; depth > 0;) {
735
- if (this.mark('<'))
736
- depth += 1;
737
- else if (this.mark('>'))
738
- depth -= 1;
739
- else if (this.identifier() === null && !TYPE_MARKS.some((mark) => this.mark(mark)))
950
+ do {
951
+ if (!this.type(false))
740
952
  return false;
953
+ } while (this.mark(','));
954
+ return this.mark('>');
955
+ }
956
+ /**
957
+ * Passes over a type, as the specification has one (8.1 and 24.3): a
958
+ * tuple of two or more, a keyword that names a type, a function pointer
959
+ * or a name with its type arguments, then the marks of a nullable, an
960
+ * array and a pointer. A pointer, a function pointer among them, only where
961
+ * `pointers` lets one stand: in an alias that is `unsafe`, and never in a
962
+ * tuple or a type argument. False where the text is none, and where it
963
+ * nests deeper than MAX_EXPANSION, as a Rust use group may not, with
964
+ * `overflow` set: so no type nested twenty thousand deep runs the reader
965
+ * out of stack.
966
+ */
967
+ type(pointers) {
968
+ if (this.nesting >= MAX_EXPANSION) {
969
+ this.overflow = true;
970
+ return false;
741
971
  }
742
- return true;
972
+ this.nesting += 1;
973
+ const read = this.typeWithin(pointers);
974
+ this.nesting -= 1;
975
+ return read;
743
976
  }
744
- /** The array, nullable and pointer marks after a type, `[]`, `[,]`, `?` and `*`, and false for `[` left open. */
745
- suffixes() {
977
+ typeWithin(pointers) {
978
+ let pointer = false;
979
+ // Where no word is next, at the end of the text or before a mark, `''`
980
+ // is none of what a word is compared with, `delegate`, `void` and the
981
+ // type keywords: any other such string for it is an equivalent mutant.
982
+ const word = this.next()?.word ?? '';
983
+ if (this.mark('(')) {
984
+ let elements = 0;
985
+ do {
986
+ if (!this.type(false))
987
+ return false;
988
+ // The name an element may have.
989
+ this.identifier();
990
+ elements += 1;
991
+ } while (this.mark(','));
992
+ if (elements < 2 || !this.mark(')'))
993
+ return false;
994
+ }
995
+ else if (word === 'delegate' || word === 'void') {
996
+ this.keyword(word);
997
+ if (!this.mark('*') || (word === 'delegate' && !this.functionPointer()))
998
+ return false;
999
+ pointer = true;
1000
+ }
1001
+ else if (CSHARP_TYPE_KEYWORDS.has(word)) {
1002
+ this.keyword(word);
1003
+ }
1004
+ else {
1005
+ // Before the names its arguments hold, which reading it put here.
1006
+ const at = this.names.length;
1007
+ const name = this.name(true);
1008
+ if (name === null)
1009
+ return false;
1010
+ this.names.splice(at, 0, name);
1011
+ }
746
1012
  for (;;) {
747
- if (this.mark('?') || this.mark('*'))
1013
+ if (this.mark('?'))
1014
+ continue;
1015
+ if (this.mark('*')) {
1016
+ pointer = true;
748
1017
  continue;
1018
+ }
749
1019
  if (!this.mark('['))
750
- return true;
1020
+ return pointers || !pointer;
751
1021
  while (this.mark(','))
752
1022
  continue;
753
1023
  if (!this.mark(']'))
754
1024
  return false;
755
1025
  }
756
1026
  }
1027
+ /**
1028
+ * What follows `delegate*`: a calling convention or none, then `<`, the
1029
+ * parameters, each `ref`, `ref readonly`, `in` or `out` or none, and the
1030
+ * return type, `void` among them, then `>` (24.3.3).
1031
+ */
1032
+ functionPointer() {
1033
+ if (this.keyword('unmanaged') && this.mark('[')) {
1034
+ do {
1035
+ if (this.identifier() === null)
1036
+ return false;
1037
+ } while (this.mark(','));
1038
+ if (!this.mark(']'))
1039
+ return false;
1040
+ }
1041
+ else {
1042
+ this.keyword('managed');
1043
+ }
1044
+ if (!this.mark('<'))
1045
+ return false;
1046
+ do {
1047
+ if (this.keyword('ref'))
1048
+ this.keyword('readonly');
1049
+ else if (!this.keyword('in'))
1050
+ this.keyword('out');
1051
+ if (!this.keyword('void') && !this.type(true))
1052
+ return false;
1053
+ } while (this.mark(','));
1054
+ return this.mark('>');
1055
+ }
757
1056
  }
758
1057
  /**
759
- * The namespace or type a using directive names, read from after `using`,
760
- * or null where C# would refuse it:
1058
+ * The namespaces and types a using directive names, read from after
1059
+ * `using`, or null where C# would refuse it:
761
1060
  *
762
1061
  * using static unsafe? type ; using unsafe? alias = name-or-type ; using namespace ;
763
1062
  *
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.
1063
+ * `global` before `using` changes nothing of it. A type is read whole: a
1064
+ * name, then each type its arguments name, and each element of a tuple; a
1065
+ * keyword that names a type, `int` or `string`, names nothing, and nor does
1066
+ * a tuple element's own name, and an alias of a type that names nothing is
1067
+ * no refusal. A using statement and a using declaration are none of these,
1068
+ * and are read as nothing.
766
1069
  */
767
1070
  function usingDirective(tokens) {
768
1071
  let target;
769
1072
  if (tokens.keyword('static')) {
770
1073
  tokens.keyword('unsafe');
771
- target = tokens.name(true);
1074
+ const name = tokens.name(true);
1075
+ target = name === null ? null : [name, ...tokens.names];
772
1076
  }
773
1077
  else {
774
1078
  const unsafe = tokens.keyword('unsafe');
775
1079
  const start = tokens.index;
776
1080
  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())
1081
+ // An alias may name any type (14.6.2), and a pointer of one where it
1082
+ // is `unsafe`.
1083
+ if (!tokens.type(unsafe))
780
1084
  return null;
1085
+ target = tokens.names;
781
1086
  }
782
1087
  else if (unsafe) {
783
1088
  return null;
784
1089
  }
785
1090
  else {
786
1091
  tokens.index = start;
787
- target = tokens.name(false);
1092
+ const name = tokens.name(false);
1093
+ target = name === null ? null : [name];
788
1094
  }
789
1095
  }
790
- return tokens.mark(';') ? target : null;
1096
+ // A type named twice, `G.L<G.L<int>>`, is one dependency. A refused
1097
+ // directive stays null, not an empty list, which is what says it refused.
1098
+ return tokens.mark(';') && target !== null ? [...new Set(target)] : null;
1099
+ }
1100
+ /**
1101
+ * Whether the tokens after a `using` start a using statement or a using
1102
+ * declaration, which top-level statements may hold where a directive may
1103
+ * stand: `(`, or a type and the name of a local, `using var x = ...;` and
1104
+ * `using N.D? d = null;` among them. Each is a statement the compiler reads
1105
+ * as one, refused or not, and no directive it refuses.
1106
+ */
1107
+ function startsAStatement(code, from) {
1108
+ const tokens = new CsharpTokens(code, from);
1109
+ return tokens.mark('(') || (tokens.type(true) && tokens.identifier() !== null);
1110
+ }
1111
+ const CSHARP_SPACE_CHARACTER = new RegExp(`[${CSHARP_BLANK}${CSHARP_BREAK}]`, 'u');
1112
+ const CSHARP_GLOBAL_BEFORE = new RegExp(String.raw `(?<![${CSHARP_PART}@\\])global$`, 'u');
1113
+ /**
1114
+ * Whether a `using` at `at` stands where a directive may start: at the head
1115
+ * of the file, after `;` or a brace, or after `global`. Where it follows
1116
+ * anything else, `await` or an `)`, it starts a statement.
1117
+ */
1118
+ function startsADirective(code, at) {
1119
+ let before = at;
1120
+ while (CSHARP_SPACE_CHARACTER.test(code[before - 1] ?? ''))
1121
+ before -= 1;
1122
+ return before === 0 || ';{}'.includes(code[before - 1]) || CSHARP_GLOBAL_BEFORE.test(code.slice(Math.max(0, before - 7), before));
1123
+ }
1124
+ /** The `#if` or `#endif` a pre-processing line is, or neither. */
1125
+ const CSHARP_CONDITION = new RegExp(String.raw `^[${CSHARP_BLANK}]*#[${CSHARP_BLANK}]*(if|endif)(?![${CSHARP_PART}])`, 'u');
1126
+ /**
1127
+ * The half-open ranges from each outermost `#if` to the end of its `#endif`.
1128
+ * The compiler skips the text of a section whose condition is false and reads
1129
+ * none of it (6.5.5), and which condition holds is the build's to say: so a
1130
+ * directive there is read as one anywhere else, and none is said to be
1131
+ * refused. One left open runs to the end of the file.
1132
+ */
1133
+ function conditionalRanges(code) {
1134
+ const ranges = [];
1135
+ let depth = 0;
1136
+ let start = 0;
1137
+ CSHARP_DIRECTIVE.lastIndex = 0;
1138
+ let line;
1139
+ while ((line = CSHARP_DIRECTIVE.exec(code)) !== null) {
1140
+ const kind = CSHARP_CONDITION.exec(line[0])?.[1];
1141
+ if (kind === 'if' && depth++ === 0)
1142
+ start = line.index;
1143
+ if (kind === 'endif' && depth > 0 && --depth === 0)
1144
+ ranges.push([start, line.index + line[0].length]);
1145
+ }
1146
+ if (depth > 0)
1147
+ ranges.push([start, code.length]);
1148
+ return ranges;
791
1149
  }
792
1150
  /**
793
1151
  * Reads using directives and the namespaces a file declares, in one pass.
@@ -808,11 +1166,18 @@ function usingDirective(tokens) {
808
1166
  * A directive and a declaration are read token by token, as the C#
809
1167
  * specification has them (ADR-0008, amended 2026-10-18): white space and
810
1168
  * comments between the tokens, an identifier as C# compares two, and what
811
- * C# refuses read as nothing.
1169
+ * C# refuses read as nothing. Each type a directive names is read whole,
1170
+ * the types its arguments name and a tuple's elements among them, and each
1171
+ * is a reference. What it refuses is said where it starts as a directive:
1172
+ * in no brace but a namespace's, where a directive may start, where no
1173
+ * statement does, and outside every `#if` (amended 2026-10-19).
812
1174
  */
813
1175
  function readCsharp(collector) {
814
1176
  // 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));
1177
+ const marked = collector.masked.code.replace(/^\u{feff}/u, ' ');
1178
+ const conditions = conditionalRanges(marked);
1179
+ let condition = 0;
1180
+ const code = marked.replace(CSHARP_DIRECTIVE, (line) => ' '.repeat(line.length));
816
1181
  const declared = new Set();
817
1182
  const open = [];
818
1183
  let fileScoped;
@@ -851,9 +1216,23 @@ function readCsharp(collector) {
851
1216
  }
852
1217
  }
853
1218
  else {
854
- const target = usingDirective(tokens);
855
- if (target !== null)
856
- emit(collector, target, 'using', match.index, open.at(-1)?.name ?? fileScoped);
1219
+ const targets = usingDirective(tokens);
1220
+ // A section starts at the head of its `#if` line and ends at its
1221
+ // `#endif` line's end, where no `using` starts: `<` for either `<=`
1222
+ // is an equivalent mutant.
1223
+ while (condition < conditions.length && conditions[condition][1] <= match.index)
1224
+ condition += 1;
1225
+ const conditional = condition < conditions.length && conditions[condition][0] <= match.index;
1226
+ if (targets !== null) {
1227
+ for (const target of targets)
1228
+ emit(collector, target, 'using', match.index, open.at(-1)?.name ?? fileScoped);
1229
+ }
1230
+ else if (tokens.overflow) {
1231
+ note(collector, 'truncated', match.index, `a using directive nests its types deeper than ${MAX_EXPANSION}`);
1232
+ }
1233
+ else if (!conditional && depth === (open.at(-1)?.depth ?? 0) && startsADirective(code, match.index) && !startsAStatement(code, CSHARP_TOKEN.lastIndex)) {
1234
+ note(collector, 'refused', match.index, 'a directive that starts as a using is one the C# compiler refuses, and no namespace is read from it');
1235
+ }
857
1236
  }
858
1237
  // On from where reading stopped, so that no token is read twice. No name
859
1238
  // is `using` or `namespace`, so no directive is read into the next.