@descent-vtt/spec-guard 0.26.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/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 C# source string is possible but says
141
- * nothing about C#.
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) {
@@ -442,12 +380,18 @@ function readPython(collector) {
442
380
  while ((match = PYTHON_STATEMENT.exec(line.text)) !== null) {
443
381
  const tokens = pythonTokens(line.text, PYTHON_STATEMENT.lastIndex);
444
382
  const modules = tokens === null ? null : pythonImports(tokens, match[4] === 'from', match[3] !== undefined);
445
- if (modules === null)
446
- continue;
447
383
  // Reported on the line its first word stands on, at that line's head,
448
384
  // as an import at the head of a logical line always was.
449
385
  const first = match.index + match[1].length + match[2].length;
450
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');
393
+ continue;
394
+ }
451
395
  // Python reads a name in the form NFKC gives it.
452
396
  for (const module of modules)
453
397
  emit(collector, module.normalize('NFKC'), 'import', offset);
@@ -496,58 +440,148 @@ function readGo(collector) {
496
440
  }
497
441
  }
498
442
  /* --------------------------------------------------------------------- rust */
499
- const RUST_USE = /\b(?:use|extern[ \t\r\n]+crate)\b/g;
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}`;
500
445
  /**
501
- * Expands one `use` path into the module paths it actually names.
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');
450
+ /**
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:
502
477
  *
503
- * `a::{b, c::{d, e}}` is three dependencies written as one statement, and a
504
- * rule about `a::c` has to see them. Returns null when the expansion would
505
- * exceed MAX_EXPANSION, which the caller reports rather than truncating.
478
+ * (path ::)? * (path ::)? { tree, ... } path (as name)?
479
+ *
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.
506
483
  */
507
- export function expandUsePath(input) {
508
- const text = input.trim();
509
- const open = text.indexOf('{');
510
- if (open === -1) {
511
- // `a::b as c` and `a::b;` both name `a::b`. Only what stands before the
512
- // alias is read, and white space left at its end is taken off on the next
513
- // line: one space on either side of `as` splits at a place that reads the
514
- // same, so the pattern without either `+` is an equivalent mutant.
515
- const leaf = text.split(/\s+as\s+/)[0];
516
- const trimmed = leaf.replace(/[;\s]+$/, '').replace(/^::/, '');
517
- return trimmed ? [trimmed] : [];
518
- }
519
- const close = matchingClose(text, open, '{', '}');
520
- const prefix = text.slice(0, open).replace(/::\s*$/, '').trim();
521
- const inner = text.slice(open + 1, close);
522
- const parts = [];
523
- let depth = 0;
524
- let current = '';
525
- for (const char of inner) {
526
- if (char === '{')
527
- depth += 1;
528
- if (char === '}')
529
- depth -= 1;
530
- if (char === ',' && depth === 0) {
531
- parts.push(current);
532
- current = '';
533
- continue;
534
- }
535
- current += char;
536
- }
537
- parts.push(current);
538
- const results = [];
539
- for (const part of parts) {
540
- const expanded = expandUsePath(part);
541
- 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))
542
489
  return null;
543
- for (const item of expanded) {
544
- // `use a::{self, b}` names `a` itself alongside `a::b`.
545
- results.push(item === 'self' || !prefix ? prefix || item : `${prefix}::${item}`);
546
- if (results.length > MAX_EXPANSION)
547
- return null;
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';
548
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';
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;
549
583
  }
550
- return results;
584
+ return next?.text === ';' ? crate.text : null;
551
585
  }
552
586
  function readRust(collector) {
553
587
  const { code } = collector.masked;
@@ -555,35 +589,206 @@ function readRust(collector) {
555
589
  let match;
556
590
  while ((match = RUST_USE.exec(code)) !== null) {
557
591
  const start = match.index + match[0].length;
558
- let end = start;
559
- let depth = 0;
560
- while (end < code.length) {
561
- const char = code[end];
562
- if (char === '{')
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('<'))
563
736
  depth += 1;
564
- else if (char === '}') {
565
- if (depth === 0)
566
- break;
737
+ else if (this.mark('>'))
567
738
  depth -= 1;
568
- }
569
- else if (char === ';' && depth === 0)
570
- break;
571
- end += 1;
739
+ else if (this.identifier() === null && !TYPE_MARKS.some((mark) => this.mark(mark)))
740
+ return false;
572
741
  }
573
- const expanded = expandUsePath(code.slice(start, end));
574
- if (expanded === null) {
575
- note(collector, 'truncated', match.index, `a use declaration expands to more than ${MAX_EXPANSION} module paths`);
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;
781
+ }
782
+ else if (unsafe) {
783
+ return null;
576
784
  }
577
785
  else {
578
- for (const specifier of expanded)
579
- emit(collector, specifier, 'use', match.index);
786
+ tokens.index = start;
787
+ target = tokens.name(false);
580
788
  }
581
- RUST_USE.lastIndex = end;
582
789
  }
790
+ return tokens.mark(';') ? target : null;
583
791
  }
584
- /* -------------------------------------------------------------------- c-sharp */
585
- /** Everything the C# reader stops at: a using, a namespace, or a brace. */
586
- const CSHARP_TOKEN = /\b(?:using|namespace)\b|[{}]/g;
587
792
  /**
588
793
  * Reads using directives and the namespaces a file declares, in one pass.
589
794
  *
@@ -597,10 +802,17 @@ const CSHARP_TOKEN = /\b(?:using|namespace)\b|[{}]/g;
597
802
  * the brace depth falls below its body's, so nothing here compares an offset
598
803
  * with the offset of a delimiter - which no keyword can ever sit on, and which
599
804
  * would leave `<` and `<=` meaning the same thing to every test. The code is
600
- * masked, so no brace in a string or a comment counts.
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.
601
812
  */
602
813
  function readCsharp(collector) {
603
- const { code } = collector.masked;
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));
604
816
  const declared = new Set();
605
817
  const open = [];
606
818
  let fileScoped;
@@ -619,52 +831,33 @@ function readCsharp(collector) {
619
831
  open.pop();
620
832
  continue;
621
833
  }
622
- const reader = new Reader(code);
623
- reader.index = match.index + token.length;
624
- reader.skipSpace();
834
+ const tokens = new CsharpTokens(code, CSHARP_TOKEN.lastIndex);
625
835
  if (token === 'namespace') {
626
- // `@namespace` is an identifier rather than the keyword, and no name
627
- // follows it.
628
- const name = reader.dotted();
629
- if (name === '')
630
- continue;
631
- reader.skipSpace();
632
- const outer = open.at(-1);
633
- const full = outer === undefined ? name : `${outer.name}.${name}`;
634
- // A file-scoped namespace covers the rest of the file, and C# allows
635
- // neither a second one nor a block beside it.
636
- if (reader.peek() === ';')
637
- fileScoped = full;
638
- else if (reader.peek() === '{')
639
- open.push({ name: full, depth: depth + 1 });
640
- else
641
- continue;
642
- declared.add(full);
643
- 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
+ }
644
852
  }
645
- if (reader.keyword('static'))
646
- reader.skipSpace();
647
- let target = reader.dotted();
648
- reader.skipSpace();
649
- // `using Alias = System.Text.Json;` - the dependency is the right-hand side.
650
- if (reader.eat('=')) {
651
- reader.skipSpace();
652
- target = reader.dotted();
653
- reader.skipSpace();
654
- reader.skipGenerics();
655
- 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);
656
857
  }
657
- // The whole of what separates a directive from a resource statement, and
658
- // it is one condition on purpose. `using (var s = ...)` reads no name at
659
- // all and stops on `(`; `using var s = ...` reads `var` and then finds `s`
660
- // where a terminator has to be. Earlier versions guarded those two shapes
661
- // explicitly, and a third time against an empty name - none of the three
662
- // changed an outcome, which mutation testing established by leaving the
663
- // tests green with each of them defeated. `emit` refuses an empty
664
- // specifier, so this line is the only check that has to be right.
665
- if (reader.peek() !== ';')
666
- continue;
667
- 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;
668
861
  }
669
862
  collector.namespaces = [...declared];
670
863
  }