ucn 5.5.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,6 +96,8 @@ function createState(index, inventory = null) {
96
96
  textualMemo: new Map(),
97
97
  productive: new Map(),
98
98
  hygieneSeq: 0,
99
+ contexts: new Map(), // file -> calls-pass context (fix #405)
100
+ resolved: new Map(), // invocation -> resolveMacro answer
99
101
  };
100
102
  }
101
103
 
@@ -317,6 +319,13 @@ function lineOffset(state, file, line) {
317
319
  return starts[Math.min(Math.max(line - 1, 0), starts.length - 1)];
318
320
  }
319
321
 
322
+ /** Offset just past `line` (its newline included), or the content's end. */
323
+ function lineEndOffset(state, file, line) {
324
+ lineOffset(state, file, 1);
325
+ const starts = state.lineStarts.get(file);
326
+ return line < starts.length ? starts[line] : (readContent(state, file) || '').length;
327
+ }
328
+
320
329
  function pickLatest(candidates) {
321
330
  return candidates.reduce((best, candidate) => (!best || candidate.pos > best.pos ? candidate : best), null);
322
331
  }
@@ -327,6 +336,16 @@ function pickLatest(candidates) {
327
336
  * in scope).
328
337
  */
329
338
  function resolveMacro(state, file, offset, line, name, qualifier, dollarCratePackage) {
339
+ // An invocation's answer is asked again by the pass that expands it
340
+ // (fix #405: fileMayExpand resolved every invocation first).
341
+ const key = `${file}\0${offset}\0${line}\0${name}\0${qualifier || ''}\0${dollarCratePackage || ''}`;
342
+ if (state.resolved.has(key)) return state.resolved.get(key);
343
+ const result = resolveMacroUncached(state, file, offset, line, name, qualifier, dollarCratePackage);
344
+ state.resolved.set(key, result);
345
+ return result;
346
+ }
347
+
348
+ function resolveMacroUncached(state, file, offset, line, name, qualifier, dollarCratePackage) {
330
349
  const all = state.inventory.get(name);
331
350
  if (!all || all.length === 0) return null;
332
351
  const inPackage = pkgRoot => all.filter(def => packageOf(state, def.file)?.root === pkgRoot);
@@ -801,6 +820,9 @@ function flatten(tokens, out = []) {
801
820
 
802
821
  const MULTI_PUNCT = ['::', '->', '=>', '==', '!=', '<=', '>=', '&&', '||', '+=', '-=', '*=', '/=',
803
822
  '%=', '^=', '&=', '|=', '<<', '>>', '..', '//', '/*', '*/'];
823
+ // The two-character joints that start a multi-character punctuation token
824
+ // (a one-character joint, from an empty leaf, starts any that begins with it).
825
+ const MULTI_PUNCT_JOINTS = new Set(MULTI_PUNCT.flatMap(p => [p.slice(0, 2), p[0]]));
804
826
  const SPACE_AFTER = new Set([',', ';', ':', '=>', '->', '=', '+', '|', '&&', '||', '==', '!=']);
805
827
  const SPACE_BEFORE = new Set(['=>', '->', '=', '+', '{', '==', '!=', '&&', '||']);
806
828
  // ASCII letters, digits, '_', quotes (and '#' at a word's end): a leaf that
@@ -832,10 +854,13 @@ function needsSpace(prev, leaf) {
832
854
  if (wordEnd && wordStart) return true;
833
855
  const a = prev.v;
834
856
  const b = leaf.v;
835
- if (b === '.' && /^[0-9]/.test(a)) return true;
857
+ if (b === '.') {
858
+ const first = String(a).charCodeAt(0);
859
+ if (first >= 48 && first <= 57) return true;
860
+ }
836
861
  if (!wordEnd && !wordStart) {
837
862
  const joint = a.slice(-1) + b[0];
838
- if (MULTI_PUNCT.some(p => p.startsWith(joint)) && !(a === '::' || b === '::')) return true;
863
+ if (MULTI_PUNCT_JOINTS.has(joint) && !(a === '::' || b === '::')) return true;
839
864
  }
840
865
  if (a === ':' && b === ':') return true;
841
866
  if (SPACE_AFTER.has(a) && a !== '::') return !(a === '|' && prev.closer);
@@ -949,7 +974,8 @@ function buildSynthetic(content, regions, keep) {
949
974
  region.synthStart = text.length;
950
975
  text += region.layout.text;
951
976
  region.synthEnd = text.length;
952
- addSpan(region.synthStart, region.synthEnd);
977
+ // A stand-in for an expansion another group reads keeps its length.
978
+ if (!region.blank) addSpan(region.synthStart, region.synthEnd);
953
979
  cursor = region.end;
954
980
  }
955
981
  copy(cursor, content.length);
@@ -1205,11 +1231,29 @@ const DECLARATION_TYPES = [
1205
1231
  'field_declaration', 'enum_variant', 'associated_type',
1206
1232
  ];
1207
1233
 
1208
- /** (startLine\0name) keys of declarations whose name was produced inside a region. */
1209
- function generatedDeclarations(root, regions) {
1234
+ const DECLARATION_TYPE_SET = new Set(DECLARATION_TYPES);
1235
+ let declarationWalkTypes = null;
1236
+ function declarationWalkTypesOf() {
1237
+ return declarationWalkTypes || (declarationWalkTypes =
1238
+ [...new Set([...DECLARATION_TYPES, ...require('../languages/rust').DECLARATION_NODE_TYPES])]);
1239
+ }
1240
+
1241
+ /**
1242
+ * (startLine\0name) keys of declarations whose name was produced inside a
1243
+ * region. `within`: the roots spanning the regions and their walked
1244
+ * descendants, when every such declaration lies inside one (else the whole
1245
+ * tree is searched).
1246
+ */
1247
+ function generatedDeclarations(root, regions, within = null) {
1210
1248
  const out = new Map();
1211
1249
  if (regions.length === 0) return out;
1212
- for (const node of root.descendantsOfType(DECLARATION_TYPES)) {
1250
+ const nodes = within
1251
+ ? within.roots.flatMap((spanning, i) => [
1252
+ ...(DECLARATION_TYPE_SET.has(spanning.type) ? [spanning] : []),
1253
+ ...within.walked[i].filter(node => DECLARATION_TYPE_SET.has(node.type)),
1254
+ ])
1255
+ : root.descendantsOfType(DECLARATION_TYPES);
1256
+ for (const node of nodes) {
1213
1257
  const nameNode = node.type === 'impl_item' ? node.childForFieldName('type')
1214
1258
  : node.childForFieldName('name');
1215
1259
  if (!nameNode) continue;
@@ -1244,24 +1288,13 @@ function macroFact(region, origin) {
1244
1288
  }
1245
1289
 
1246
1290
  /**
1247
- * Expand the project macro invocations of one file. Returns the expansion
1248
- * record, or null when the file invokes no project macro in scope.
1291
+ * The invocations of a file the expansion pass reads, in document order:
1292
+ * project macros in scope that are worth expanding. Invocations without a
1293
+ * context or token tree are recorded blind in `rec`.
1249
1294
  */
1250
- function expandFile(state, filePath, mode = 'symbols') {
1251
- // Hygiene contexts are numbered per file, so an expansion's renamed
1252
- // locals do not depend on what else this state expanded before.
1253
- state.hygieneSeq = 0;
1254
- const index = state.index;
1255
- const fileEntry = index.files.get(filePath);
1256
- const content = readContent(state, filePath);
1257
- if (!fileEntry || content === null) return null;
1258
- if (!fileMayExpand(state, filePath)) return { sites: 0, blind: [], used: new Map(), expanded: false };
1259
- const tree = safeParse(state.parser, content, undefined, PARSE_OPTIONS);
1260
- const rec = { sites: 0, blind: [], used: new Map(), expanded: false };
1261
- const regions = [];
1262
- let lineStarts = null;
1263
- const invocations = tree.rootNode.descendantsOfType('macro_invocation');
1264
- for (const node of invocations) {
1295
+ function plannedInvocations(state, filePath, content, tree, rec, invocations = null) {
1296
+ const plan = [];
1297
+ for (const node of invocations || tree.rootNode.descendantsOfType('macro_invocation')) {
1265
1298
  const id = invocationName(node);
1266
1299
  if (!id || !state.inventory.has(id.name)) continue;
1267
1300
  let inDefinition = false;
@@ -1279,53 +1312,96 @@ function expandFile(state, filePath, mode = 'symbols') {
1279
1312
  context === 'expr')) continue;
1280
1313
  const tokenTree = node.namedChildren.find(child => child.type === 'token_tree');
1281
1314
  if (info.compiled?.passThroughItems && context === 'items' && itemRecoveryPosition(node)) continue;
1282
- const blind = reason => rec.blind.push({ line, macro: id.name, reason });
1283
- if (!context) { blind('context'); continue; }
1284
- if (!tokenTree) { blind('token-error'); continue; }
1285
- const site = {
1286
- file: filePath, offset: node.startIndex, line, budget: MAX_OUTPUT_TOKENS,
1287
- used: rec.used, nestedBlind: [], ambiguous: resolved.ambiguous,
1288
- };
1289
- let tokens;
1290
- try {
1291
- if (!lineStarts) {
1292
- lineOffset(state, filePath, 1);
1293
- lineStarts = state.lineStarts.get(filePath);
1294
- }
1295
- const toks = argTokensOf(tokenTree, content, lineStarts);
1296
- tokens = resolved.alternatives
1297
- ? expandAlternatives(state, site, resolved, toks, 0, context)
1298
- : expandTokens(state, site, def, toks, 0);
1299
- } catch (error) {
1300
- if (!(error instanceof MacroBlind)) throw error;
1301
- blind(error.reason);
1302
- continue;
1303
- }
1304
- for (const nested of site.nestedBlind) rec.blind.push({ line, macro: nested.macro, reason: `nested:${nested.reason}` });
1305
- renameHygienicLocals(tokens);
1306
- let body = tokens;
1307
- if (context === 'expr') body = wrapExpression(tokens);
1308
- const startLine = line;
1309
- const endLine = node.endPosition.row + 1;
1310
- const ancestors = new Set();
1311
- for (let parent = node.parent; parent; parent = parent.parent) {
1312
- const nameNode = parent.type === 'impl_item' ? parent.childForFieldName('type')
1313
- : DECLARATION_TYPES.includes(parent.type) ? parent.childForFieldName('name') : null;
1314
- if (nameNode) {
1315
- ancestors.add(`${parent.startPosition.row + 1}\0${parent.type === 'impl_item' ? typeNameOf(nameNode) : nameNode.text}`);
1316
- }
1315
+ if (!context) { rec.blind.push({ line, macro: id.name, reason: 'context' }); continue; }
1316
+ if (!tokenTree) { rec.blind.push({ line, macro: id.name, reason: 'token-error' }); continue; }
1317
+ plan.push({ node, id, resolved, def, context, tokenTree, line });
1318
+ }
1319
+ return plan;
1320
+ }
1321
+
1322
+ /** Expand one planned invocation: its region, or null (recorded blind). */
1323
+ function expandPlanned(state, filePath, content, item, rec) {
1324
+ const { node, id, resolved, def, context, tokenTree, line } = item;
1325
+ const site = {
1326
+ file: filePath, offset: node.startIndex, line, budget: MAX_OUTPUT_TOKENS,
1327
+ used: rec.used, nestedBlind: [], ambiguous: resolved.ambiguous,
1328
+ };
1329
+ let tokens;
1330
+ try {
1331
+ lineOffset(state, filePath, 1);
1332
+ const toks = argTokensOf(tokenTree, content, state.lineStarts.get(filePath));
1333
+ tokens = resolved.alternatives
1334
+ ? expandAlternatives(state, site, resolved, toks, 0, context)
1335
+ : expandTokens(state, site, def, toks, 0);
1336
+ } catch (error) {
1337
+ if (!(error instanceof MacroBlind)) throw error;
1338
+ rec.blind.push({ line, macro: id.name, reason: error.reason });
1339
+ return null;
1340
+ }
1341
+ for (const nested of site.nestedBlind) rec.blind.push({ line, macro: nested.macro, reason: `nested:${nested.reason}` });
1342
+ renameHygienicLocals(tokens);
1343
+ let body = tokens;
1344
+ if (context === 'expr') body = wrapExpression(tokens);
1345
+ const startLine = line;
1346
+ const endLine = node.endPosition.row + 1;
1347
+ const ancestors = new Set();
1348
+ for (let parent = node.parent; parent; parent = parent.parent) {
1349
+ const nameNode = parent.type === 'impl_item' ? parent.childForFieldName('type')
1350
+ : DECLARATION_TYPES.includes(parent.type) ? parent.childForFieldName('name') : null;
1351
+ if (nameNode) {
1352
+ ancestors.add(`${parent.startPosition.row + 1}\0${parent.type === 'impl_item' ? typeNameOf(nameNode) : nameNode.text}`);
1317
1353
  }
1318
- regions.push({
1319
- start: node.startIndex, end: node.endIndex, startLine, endLine,
1320
- macro: id.name, definition: defKey(def), ambiguous: site.ambiguous, ancestors,
1321
- node, layout: layout(body, startLine, node.startPosition.column, endLine),
1322
- });
1354
+ }
1355
+ return {
1356
+ start: node.startIndex, end: node.endIndex, startLine, endLine,
1357
+ macro: id.name, definition: defKey(def), ambiguous: site.ambiguous, ancestors,
1358
+ node, layout: layout(body, startLine, node.startPosition.column, endLine),
1359
+ };
1360
+ }
1361
+
1362
+ /**
1363
+ * Expand the project macro invocations of one file. Returns the expansion
1364
+ * record, or null when the file invokes no project macro in scope.
1365
+ */
1366
+ function expandFile(state, filePath, mode = 'symbols') {
1367
+ // Hygiene contexts are numbered per file, so an expansion's renamed
1368
+ // locals do not depend on what else this state expanded before.
1369
+ state.hygieneSeq = 0;
1370
+ const index = state.index;
1371
+ const fileEntry = index.files.get(filePath);
1372
+ const content = readContent(state, filePath);
1373
+ if (!fileEntry || content === null) return null;
1374
+ if (!fileMayExpand(state, filePath)) return { sites: 0, blind: [], used: new Map(), expanded: false };
1375
+ const tree = safeParse(state.parser, content, undefined, PARSE_OPTIONS);
1376
+ const rec = { sites: 0, blind: [], used: new Map(), expanded: false };
1377
+ const regions = [];
1378
+ // Each expansion's hygiene start and expanded length (fix #405): the
1379
+ // calls pass expands one group of units with the same numbering and
1380
+ // the same offsets as a pass over the whole file.
1381
+ const expansions = mode === 'symbols' ? [] : null;
1382
+ // The symbols pass also needs the file's literals and comments: one
1383
+ // walk lists both (fix #405b).
1384
+ const clean = !tree.rootNode.hasError;
1385
+ const walked = mode === 'symbols' && clean
1386
+ ? tree.rootNode.descendantsOfType(['macro_invocation', ...NON_CODE_TOKENS]) : null;
1387
+ const invocations = walked ? walked.filter(node => node.type === 'macro_invocation') : null;
1388
+ const nonCode = walked ? walked.filter(node => node.type !== 'macro_invocation') : [];
1389
+ for (const item of plannedInvocations(state, filePath, content, tree, rec, invocations)) {
1390
+ const hygiene = state.hygieneSeq;
1391
+ const region = expandPlanned(state, filePath, content, item, rec);
1392
+ if (expansions) expansions.push(item.node.startIndex, hygiene, region ? region.layout.text.length : -1);
1393
+ if (region) regions.push(region);
1323
1394
  }
1324
1395
  rec.sites = regions.length;
1325
1396
  if (regions.length === 0) return rec;
1326
1397
  regions.sort((a, b) => a.start - b.start);
1398
+ // The identifiers each expansion outputs.
1399
+ const outputs = mode === 'symbols' ? regions.map(outputWordsOf) : null;
1327
1400
  // Every word the expanded text of the calls pass can hold (fix #375).
1328
- const words = mode === 'symbols' ? expansionWords(content, regions) : null;
1401
+ const words = mode === 'symbols' ? expansionWords(content, regions, nonCode, outputs) : null;
1402
+ // What each group of units can bear, read before parsing it (fix #405b).
1403
+ const groups = mode === 'symbols' && !tree.rootNode.hasError
1404
+ ? groupRecords(content, regions, nonCode, outputs) : null;
1329
1405
 
1330
1406
  // Parse the expanded file; an expansion that does not parse in its
1331
1407
  // context stays as written (blind), and the rest are parsed again.
@@ -1356,41 +1432,14 @@ function expandFile(state, filePath, mode = 'symbols') {
1356
1432
  if (regions.length === 0) return rec;
1357
1433
 
1358
1434
  const rust = require('../languages/rust');
1359
- // The expanded file holds no unexpanded item bodies worth recovering
1360
- // inside its units; the extractors share the included-ranges tree.
1361
- rust.primeDeclarationTrees(synthetic, state.parser, synthTree);
1362
1435
  const mapper = offsetMapper(regions);
1363
-
1364
- // Units are re-derived; everything outside them keeps its parse.
1365
- // (Sorted and disjoint: binary searches.)
1366
- const unitAtOffset = offset => {
1367
- let lo = 0;
1368
- let hi = units.length - 1;
1369
- while (lo <= hi) {
1370
- const mid = (lo + hi) >> 1;
1371
- if (offset < units[mid].start) hi = mid - 1;
1372
- else if (offset >= units[mid].end) lo = mid + 1;
1373
- else return units[mid];
1374
- }
1375
- return null;
1376
- };
1377
- const byLine = (list, line) => {
1378
- let lo = 0;
1379
- let hi = list.length - 1;
1380
- while (lo <= hi) {
1381
- const mid = (lo + hi) >> 1;
1382
- if (line < list[mid].startLine) hi = mid - 1;
1383
- else if (line > list[mid].endLine) lo = mid + 1;
1384
- else return list[mid];
1385
- }
1386
- return null;
1387
- };
1388
- const unitAtLine = line => byLine(units, line);
1389
- const inUnit = record => (Number.isFinite(record.callStart) ? !!unitAtOffset(record.callStart)
1390
- : !!unitAtLine(record.line));
1436
+ const lookups = unitLookups(units);
1391
1437
  rec.expanded = true;
1392
1438
 
1393
1439
  if (mode === 'symbols') {
1440
+ // The expanded file holds no unexpanded item bodies worth recovering
1441
+ // inside its units; the extractors share the included-ranges tree.
1442
+ rust.primeDeclarationTrees(synthetic, state.parser, synthTree);
1394
1443
  // Declarations of the regions only: the smallest nodes spanning each.
1395
1444
  const spanning = regions.map(region => (region.synthEnd > region.synthStart
1396
1445
  ? synthTree.rootNode.descendantForIndex(region.synthStart, region.synthEnd - 1) : null))
@@ -1401,44 +1450,195 @@ function expandFile(state, filePath, mode = 'symbols') {
1401
1450
  if (last && node.startIndex >= last.startIndex && node.endIndex <= last.endIndex) continue;
1402
1451
  roots.push(node);
1403
1452
  }
1404
- rec.symbols = generatedSymbols(filePath, rust.parseDeclarationsIn(synthetic, roots), {
1405
- regions, mapper, generated: generatedDeclarations(synthTree.rootNode, regions),
1453
+ // One walk of each root serves both extractions (fix #405b). In a
1454
+ // file that parses cleanly an expansion stands as whole items,
1455
+ // statements or a wrapped expression, so a declaration named inside
1456
+ // it lies inside the root spanning it; otherwise the whole tree is
1457
+ // searched.
1458
+ const walked = roots.map(root => root.descendantsOfType(declarationWalkTypesOf()));
1459
+ rec.symbols = generatedSymbols(filePath, rust.parseDeclarationsIn(synthetic, roots, {
1460
+ descendants: (root, i) => walked[i],
1461
+ }), {
1462
+ regions, mapper, generated: generatedDeclarations(synthTree.rootNode, regions,
1463
+ tree.rootNode.hasError ? null : { roots, walked }),
1406
1464
  });
1407
1465
  rec.calleeNames = callableNames(regions);
1466
+ rec.expansions = expansions;
1467
+ // Groups as the calls pass reads them apart: only when no expansion
1468
+ // was left blind by a parse error (the pass then reads the file in
1469
+ // one pass, whose groups can differ).
1408
1470
  rec.words = words;
1471
+ if (groups && !rec.blind.some(site => site.reason === 'parse-error')) rec.groups = groups;
1409
1472
  rec.callsPending = true;
1410
1473
  return rec;
1411
1474
  }
1412
1475
 
1413
1476
  // Calls: records of the units from the expanded file, the rest as parsed,
1414
- // plus the invocations themselves.
1477
+ // plus the invocations themselves (a derivation context, fix #405).
1478
+ rec.context = callsContext(state, filePath, {
1479
+ content, tree, regions, units, synthetic, synthTree, mapper, lookups,
1480
+ });
1481
+ return rec;
1482
+ }
1483
+
1484
+ /** Binary-search lookups over sorted, disjoint units. */
1485
+ function unitLookups(units) {
1486
+ const unitAtOffset = offset => {
1487
+ let lo = 0;
1488
+ let hi = units.length - 1;
1489
+ while (lo <= hi) {
1490
+ const mid = (lo + hi) >> 1;
1491
+ if (offset < units[mid].start) hi = mid - 1;
1492
+ else if (offset >= units[mid].end) lo = mid + 1;
1493
+ else return units[mid];
1494
+ }
1495
+ return null;
1496
+ };
1497
+ const unitAtLine = line => byLine(units, line);
1498
+ const inUnit = record => (Number.isFinite(record.callStart) ? !!unitAtOffset(record.callStart)
1499
+ : !!unitAtLine(record.line));
1500
+ return { unitAtOffset, unitAtLine, inUnit };
1501
+ }
1502
+
1503
+ function byLine(list, line) {
1504
+ let lo = 0;
1505
+ let hi = list.length - 1;
1506
+ while (lo <= hi) {
1507
+ const mid = (lo + hi) >> 1;
1508
+ if (line < list[mid].startLine) hi = mid - 1;
1509
+ else if (line > list[mid].endLine) lo = mid + 1;
1510
+ else return list[mid];
1511
+ }
1512
+ return null;
1513
+ }
1514
+
1515
+ // ── Calls pass (fix #405) ─────────────────────────────────────────────────
1516
+ //
1517
+ // The calls of an expanded file are the parsed records outside its units,
1518
+ // the records each unit yields in the expanded file, and the invocations
1519
+ // themselves. Units sharing a line form one group (records without a span
1520
+ // are placed by line). The extractor keeps no scope across functions, so a
1521
+ // group's records do not depend on the other groups: a query that needs
1522
+ // the records bearing one name, or those of a line range, reads only the
1523
+ // groups that can hold them, and the file's calls are composed, as one pass
1524
+ // derives them, when a consumer needs them all. Where the build recorded
1525
+ // every expansion's hygiene start and expanded length, a group expands and
1526
+ // parses alone with the numbering and the offsets of the whole file;
1527
+ // otherwise the file's expansions parse in one pass and each group's
1528
+ // records are read from that tree with the other units skipped.
1529
+
1530
+ /**
1531
+ * Everything the calls pass of a file shares: its regions, units and their
1532
+ * groups, the expanded file and its tree. Per-group records are added as
1533
+ * groups are read.
1534
+ */
1535
+ function callsContext(state, filePath, parts) {
1536
+ const { content, tree, regions, units, synthetic, synthTree, mapper, lookups } = parts;
1537
+ const index = state.index;
1538
+ // Units sharing a line form one group.
1539
+ const groups = groupsOf(units);
1540
+ // Unit spans in the expanded file: a unit starts and ends outside every
1541
+ // region, shifted by the regions before it.
1542
+ const toSynth = offset => {
1543
+ let lo = 0;
1544
+ let hi = regions.length - 1;
1545
+ let before = null;
1546
+ while (lo <= hi) {
1547
+ const mid = (lo + hi) >> 1;
1548
+ if (regions[mid].end <= offset) { before = regions[mid]; lo = mid + 1; } else hi = mid - 1;
1549
+ }
1550
+ return offset + (before ? before.deltaAfter : 0);
1551
+ };
1552
+ for (const unit of units) {
1553
+ unit.synthStart = toSynth(unit.start);
1554
+ unit.synthEnd = toSynth(unit.end);
1555
+ }
1415
1556
  const cached = index.callsCache.get(filePath);
1416
1557
  const original = cached?.calls || [];
1417
- const invocationStarts = new Set(regions.map(region => region.start));
1418
- const calls = original.filter(record => !inUnit(record));
1419
- const seen = new Set();
1420
1558
  // Records without a source span (struct-literal constructors) are placed
1421
1559
  // by line: one the unexpanded parse also has on that line is ordinary
1422
1560
  // code, any other one on an invocation's lines was generated.
1423
- const spanless = record => `${record.name}\0${record.line}\0${record.isConstructor ? 1 : 0}`;
1424
1561
  const originalSpanless = new Map();
1425
1562
  for (const record of original) {
1426
- if (Number.isFinite(record.callStart) || !unitAtLine(record.line)) continue;
1427
- const key = spanless(record);
1563
+ if (Number.isFinite(record.callStart) || !lookups.unitAtLine(record.line)) continue;
1564
+ const key = spanlessKey(record);
1428
1565
  originalSpanless.set(key, (originalSpanless.get(key) || 0) + 1);
1429
1566
  }
1567
+ const rust = require('../languages/rust');
1568
+ return {
1569
+ filePath, content, tree, regions, units, groups, synthetic, synthTree, mapper, lookups,
1570
+ original, originalSpanless, seen: new Set(),
1571
+ itemRecovery: rust.rustExpandedItemRecovery(synthetic, state.parser, synthTree),
1572
+ // An expanded file whose parse holds an error is read in one pass.
1573
+ groupwise: !synthTree.rootNode.hasError,
1574
+ invocations: new Map(),
1575
+ parser: state.parser,
1576
+ live: true,
1577
+ };
1578
+ }
1579
+
1580
+ function spanlessKey(record) {
1581
+ return `${record.name}\0${record.line}\0${record.isConstructor ? 1 : 0}`;
1582
+ }
1583
+
1584
+ /** Read the records of the groups `wanted` (indices) not read yet. */
1585
+ function readGroups(ctx, wanted) {
1586
+ let targets = wanted.filter(i => ctx.groups[i].records === null);
1587
+ if (targets.length === 0) return;
1588
+ if (ctx.isolated) {
1589
+ readIsolatedGroups(ctx, targets);
1590
+ ctx.readLookups = null;
1591
+ return;
1592
+ }
1593
+ if (!ctx.groupwise) targets = ctx.groups.map((group, i) => i).filter(i => ctx.groups[i].records === null);
1594
+ const target = new Set(targets);
1595
+ const all = target.size === ctx.groups.filter(group => group.records === null).length &&
1596
+ ctx.groups.every(group => group.records === null);
1597
+ const units = ctx.units;
1598
+ // Nodes inside a unit of another group are not read.
1599
+ const skip = all ? null : node => {
1600
+ const start = node.startIndex;
1601
+ let lo = 0;
1602
+ let hi = units.length - 1;
1603
+ while (lo <= hi) {
1604
+ const mid = (lo + hi) >> 1;
1605
+ const unit = units[mid];
1606
+ if (start < unit.synthStart) hi = mid - 1;
1607
+ else if (start >= unit.synthEnd) lo = mid + 1;
1608
+ else return node.endIndex <= unit.synthEnd && !target.has(unit.group);
1609
+ }
1610
+ return false;
1611
+ };
1612
+ for (const i of targets) ctx.groups[i].records = [];
1613
+ const rust = require('../languages/rust');
1614
+ const synthCalls = rust.findCallsInCode(ctx.synthetic, ctx.parser, {
1615
+ tree: ctx.synthTree, itemRecovery: ctx.itemRecovery, skip,
1616
+ });
1617
+ mergeRecords(ctx, synthCalls, {
1618
+ mapper: ctx.mapper, lookups: ctx.lookups, regions: ctx.regions, synthTree: ctx.synthTree,
1619
+ originalSpanless: ctx.originalSpanless,
1620
+ accept: unit => target.has(unit.group), push: (unit, call) => ctx.groups[unit.group].records.push(call),
1621
+ });
1622
+ }
1623
+
1624
+ /**
1625
+ * The records an expanded file's parse yields for the accepted units, with
1626
+ * source offsets, expansion facts, and repeats dropped (`ctx.seen`).
1627
+ */
1628
+ function mergeRecords(ctx, synthCalls, { mapper, lookups, regions, synthTree, originalSpanless, accept, push }) {
1430
1629
  const regionOnLine = line => byLine(regions, line);
1431
- const synthCalls = rust.findCallsInCode(synthetic, state.parser);
1432
1630
  for (const record of synthCalls) {
1433
1631
  const synthStart = record.callStart;
1632
+ let unit;
1434
1633
  if (Number.isFinite(synthStart)) {
1435
1634
  const mapped = mapper.map(synthStart, false);
1436
- if (!unitAtOffset(Math.floor(mapped))) continue;
1437
- } else if (!unitAtLine(record.line)) continue;
1635
+ unit = lookups.unitAtOffset(Math.floor(mapped));
1636
+ } else unit = lookups.unitAtLine(record.line);
1637
+ if (!unit || !accept(unit)) continue;
1438
1638
  const call = { ...record };
1439
1639
  let region = Number.isFinite(synthStart) ? mapper.regionAt(synthStart) : null;
1440
1640
  if (!Number.isFinite(synthStart) && regionOnLine(record.line)) {
1441
- const key = spanless(record);
1641
+ const key = spanlessKey(record);
1442
1642
  const left = originalSpanless.get(key) || 0;
1443
1643
  if (left > 0) originalSpanless.set(key, left - 1);
1444
1644
  else region = regionOnLine(record.line);
@@ -1463,24 +1663,70 @@ function expandFile(state, filePath, mode = 'symbols') {
1463
1663
  : `t\0${call.name}\0${call.line}\0${call.isMethod ? 1 : 0}\0${call.receiver || ''}\0` +
1464
1664
  `${call.receiverType || ''}\0${call.receiverRootType || ''}\0${call.receiverField || ''}\0` +
1465
1665
  `${call.enclosingFunction?.name || ''}\0${call.argCount ?? ''}`;
1466
- if (seen.has(key)) continue;
1467
- seen.add(key);
1666
+ if (ctx.seen.has(key)) continue;
1667
+ ctx.seen.add(key);
1468
1668
  }
1469
- calls.push(call);
1669
+ push(unit, call);
1470
1670
  }
1471
- // The invocation stays a call of the macro; the value it produces is
1472
- // the expansion's, so its own value-flow facts are dropped.
1473
- for (const record of original) {
1474
- if (!record.isMacro || !invocationStarts.has(record.callStart)) continue;
1475
- calls.push({
1476
- name: record.name, line: record.line, callStart: record.callStart, callEnd: record.callEnd,
1477
- isMethod: false, isMacro: true,
1478
- ...(record.receiver && { receiver: record.receiver, isPathMacro: true }),
1479
- ...(record.macroExpr && { macroExpr: true }),
1480
- enclosingFunction: record.enclosingFunction,
1481
- macroExpanded: true,
1482
- });
1671
+ }
1672
+
1673
+ /**
1674
+ * The invocations stay calls of their macros; the value one produces is the
1675
+ * expansion's, so its own value-flow facts are dropped. Built once per
1676
+ * context (the same records in every view and in the composed calls).
1677
+ */
1678
+ function invocationRecords(ctx) {
1679
+ // Expanded invocations: every region (one pass), the regions of the
1680
+ // groups read so far (groups read apart).
1681
+ const starts = new Set();
1682
+ if (ctx.isolated) {
1683
+ for (const group of ctx.groups) for (const start of group.starts || []) starts.add(start);
1684
+ } else {
1685
+ for (const region of ctx.regions) starts.add(region.start);
1686
+ }
1687
+ const out = [];
1688
+ for (const record of ctx.original) {
1689
+ if (!record.isMacro || !starts.has(record.callStart)) continue;
1690
+ let invocation = ctx.invocations.get(record);
1691
+ if (!invocation) {
1692
+ invocation = {
1693
+ name: record.name, line: record.line, callStart: record.callStart, callEnd: record.callEnd,
1694
+ isMethod: false, isMacro: true,
1695
+ ...(record.receiver && { receiver: record.receiver, isPathMacro: true }),
1696
+ ...(record.macroExpr && { macroExpr: true }),
1697
+ enclosingFunction: record.enclosingFunction,
1698
+ macroExpanded: true,
1699
+ };
1700
+ ctx.invocations.set(record, invocation);
1701
+ }
1702
+ out.push(invocation);
1703
+ }
1704
+ return out;
1705
+ }
1706
+
1707
+ /** Whether a parsed record lies in a unit of a group read so far (all units, one pass). */
1708
+ function inReadUnit(ctx, record) {
1709
+ if (!ctx.isolated) return ctx.lookups.inUnit(record);
1710
+ if (!ctx.readLookups) {
1711
+ const units = [];
1712
+ for (const group of ctx.groups) if (group.units) units.push(...group.units);
1713
+ units.sort((a, b) => a.start - b.start);
1714
+ ctx.readLookups = unitLookups(units);
1483
1715
  }
1716
+ return ctx.readLookups.inUnit(record);
1717
+ }
1718
+
1719
+ /**
1720
+ * The calls of the file: parsed records outside the units, each group's
1721
+ * records, the invocations; stable by line. Installed in the calls cache
1722
+ * with the facts the file entry derives from them.
1723
+ */
1724
+ function composeCalls(index, ctx) {
1725
+ readGroups(ctx, ctx.groups.map((group, i) => i));
1726
+ const fileEntry = index.files.get(ctx.filePath);
1727
+ const calls = ctx.original.filter(record => !inReadUnit(ctx, record));
1728
+ for (const group of ctx.groups) for (const record of group.records) calls.push(record);
1729
+ for (const record of invocationRecords(ctx)) calls.push(record);
1484
1730
  // Shapes of generated calls whose value may be lost (audit-async).
1485
1731
  const { openCallShape } = require('../languages/rust-value-flow');
1486
1732
  const shapes = calls.filter(call => call.macroExpansion && call.name && !call.inMacro && !call.valueConsumed)
@@ -1490,16 +1736,16 @@ function expandFile(state, filePath, mode = 'symbols') {
1490
1736
  }
1491
1737
  // Stable by line: records keep document order within a line.
1492
1738
  calls.sort((a, b) => a.line - b.line);
1739
+ const cached = index.callsCache.get(ctx.filePath);
1493
1740
  if (cached) {
1494
- if (index.calleeIndex) index._removeFromCalleeIndex(filePath, original);
1741
+ if (index.calleeIndex) index._removeFromCalleeIndex(ctx.filePath, ctx.original);
1495
1742
  cached.calls = calls;
1496
- if (index.calleeIndex) index._addToCalleeIndex(filePath, calls);
1743
+ if (index.calleeIndex) index._addToCalleeIndex(ctx.filePath, calls);
1497
1744
  } else {
1498
- index.callsCache.set(filePath, { mtime: fileEntry.mtime, hash: fileEntry.hash, calls });
1745
+ index.callsCache.set(ctx.filePath, { mtime: fileEntry.mtime, hash: fileEntry.hash, calls });
1499
1746
  }
1500
1747
  index.callsCacheDirty = true;
1501
- rec.expanded = true;
1502
- return rec;
1748
+ return calls;
1503
1749
  }
1504
1750
 
1505
1751
  const CALL_NODES = new Set(['call_expression', 'macro_invocation', 'struct_expression', 'generic_function']);
@@ -1621,11 +1867,12 @@ function callableNames(regions) {
1621
1867
  for (const region of regions) {
1622
1868
  const marks = region.layout.marks;
1623
1869
  for (let i = 0; i < marks.length - 1; i++) {
1870
+ const next = marks[i + 1].leaf.v;
1871
+ if (!(next === '(' || next === '!' || next === '{' ||
1872
+ (next === '::' && marks[i + 2]?.leaf.v === '<'))) continue;
1624
1873
  const leaf = marks[i].leaf;
1625
1874
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(leaf.v) || RUST_KEYWORDS.has(leaf.v)) continue;
1626
- const next = marks[i + 1].leaf.v;
1627
- if (next === '(' || next === '!' || next === '{' ||
1628
- (next === '::' && marks[i + 2]?.leaf.v === '<')) names.add(leaf.v);
1875
+ names.add(leaf.v);
1629
1876
  }
1630
1877
  }
1631
1878
  return [...names].sort(codeUnitCompare);
@@ -1634,27 +1881,117 @@ function callableNames(regions) {
1634
1881
  const WORD = /[\p{L}\p{N}_]+/gu;
1635
1882
  const WHOLE_WORD = /^[\p{L}\p{N}_]+$/u;
1636
1883
 
1884
+ // Tokens whose text is never an identifier: literals and comments.
1885
+ const NON_CODE_TOKENS = ['string_literal', 'raw_string_literal', 'char_literal', 'line_comment', 'block_comment'];
1886
+
1637
1887
  /**
1638
1888
  * The words (identifier-like runs) of the text the calls pass analyzes for
1639
1889
  * these regions: the kept source around them (units and their headers) and
1640
1890
  * every expansion's tokens. A call record derived from that text can bear no
1641
1891
  * other name, so a file whose calls are not derived yet needs deriving only
1642
- * for a name among them (fix #375). Space-separated, sorted.
1892
+ * for a name among them (fix #375). A record's name and written receiver are
1893
+ * identifier tokens, so the text of literals and comments is left out (fix
1894
+ * #405: `"--count"` and `// the count` made a `count` query derive four
1895
+ * test files); kept ranges are whole items and headers, which the calls
1896
+ * pass lexes as the file's own parse did. A file with parse errors is read
1897
+ * whole. Space-separated, sorted.
1643
1898
  */
1644
- function expansionWords(content, regions) {
1899
+ function expansionWords(content, regions, nonCode = [], outputs = regions.map(outputWordsOf)) {
1645
1900
  const words = new Set();
1646
- for (const [start, end] of analysisUnits(regions, 'calls').keep) {
1901
+ scanCodeWords(content, analysisUnits(regions, 'calls').keep, nonCode, words);
1902
+ for (const output of outputs) for (const word of output) words.add(word);
1903
+ return [...words].sort(codeUnitCompare).join(' ');
1904
+ }
1905
+
1906
+ /** Words of `ranges` (sorted, disjoint) of the source, literals and comments (`nonCode`, sorted) left out. */
1907
+ function scanCodeWords(content, ranges, nonCode, words) {
1908
+ const scan = (start, end) => {
1909
+ if (end <= start) return;
1647
1910
  for (const word of content.slice(start, end).match(WORD) || []) words.add(word);
1648
- }
1649
- // A record's name is an identifier token (string contents never are;
1650
- // a non-ASCII identifier is a 'punct' leaf).
1651
- for (const region of regions) {
1652
- for (const mark of region.layout.marks) {
1653
- const leaf = mark.leaf;
1654
- if (leaf.k === 'ident' || (leaf.k === 'punct' && WHOLE_WORD.test(leaf.v))) words.add(leaf.v);
1911
+ };
1912
+ let next = 0;
1913
+ for (const [start, end] of ranges) {
1914
+ let at = start;
1915
+ while (next < nonCode.length && nonCode[next].endIndex <= at) next++;
1916
+ for (let i = next; i < nonCode.length; i++) {
1917
+ const token = nonCode[i];
1918
+ if (token.startIndex >= end) break;
1919
+ scan(at, Math.min(token.startIndex, end));
1920
+ at = Math.max(at, token.endIndex);
1655
1921
  }
1922
+ scan(at, end);
1656
1923
  }
1657
- return [...words].sort(codeUnitCompare).join(' ');
1924
+ }
1925
+
1926
+ /** The identifier tokens an expansion outputs (a non-ASCII identifier is a 'punct' leaf), once each. */
1927
+ function outputWordsOf(region) {
1928
+ const words = new Set();
1929
+ for (const mark of region.layout.marks) {
1930
+ const leaf = mark.leaf;
1931
+ if (leaf.k === 'ident' || (leaf.k === 'punct' && WHOLE_WORD.test(leaf.v))) words.add(leaf.v);
1932
+ }
1933
+ return [...words];
1934
+ }
1935
+
1936
+ /**
1937
+ * The groups of units the calls pass reads apart (fix #405b), each as
1938
+ * [startLine, endLine, expanded length, words]: the identifiers its
1939
+ * expansions output (with those of any expansion in its kept headers) and
1940
+ * the code of its kept headers outside its units (`Self { .. }` names the
1941
+ * impl's type). A record a group derives in the units' own code is written
1942
+ * there, as the file's parsed record on that line is: the calls pass
1943
+ * consults those for the rest (rustPendingRecords). Expanded length: the
1944
+ * text the group's own expansions add, for the cost of reading it.
1945
+ */
1946
+ function groupRecords(content, regions, nonCode, outputs) {
1947
+ const { units } = analysisUnits(regions, 'calls');
1948
+ const groups = groupsOf(units);
1949
+ const lookups = unitLookups(units);
1950
+ const members = groups.map(() => []);
1951
+ const groupOf = regions.map((region, i) => {
1952
+ const group = lookups.unitAtOffset(region.start).group;
1953
+ members[group].push(i);
1954
+ return group;
1955
+ });
1956
+ // Expansions are disjoint and sorted: the first one ending after `at`.
1957
+ const firstEndingAfter = at => {
1958
+ let lo = 0;
1959
+ let hi = regions.length;
1960
+ while (lo < hi) {
1961
+ const mid = (lo + hi) >> 1;
1962
+ if (regions[mid].end <= at) lo = mid + 1; else hi = mid;
1963
+ }
1964
+ return lo;
1965
+ };
1966
+ return groups.map((group, gi) => {
1967
+ const keep = analysisUnits(members[gi].map(i => regions[i]), 'calls').keep;
1968
+ const words = new Set();
1969
+ let length = 0;
1970
+ for (const i of members[gi]) {
1971
+ length += regions[i].layout.text.length;
1972
+ for (const word of outputs[i]) words.add(word);
1973
+ }
1974
+ // Expansions inside its kept headers (`impl T for m!() {..}`).
1975
+ for (const [start, end] of keep) {
1976
+ for (let i = firstEndingAfter(start); i < regions.length && regions[i].start < end; i++) {
1977
+ if (groupOf[i] !== gi) for (const word of outputs[i]) words.add(word);
1978
+ }
1979
+ }
1980
+ // Header code: the kept ranges outside the group's units.
1981
+ const headers = [];
1982
+ for (const [start, end] of keep) {
1983
+ let at = start;
1984
+ for (const unit of group.units) {
1985
+ if (unit.end <= at || unit.start >= end) continue;
1986
+ if (unit.start > at) headers.push([at, unit.start]);
1987
+ at = Math.max(at, unit.end);
1988
+ }
1989
+ if (at < end) headers.push([at, end]);
1990
+ }
1991
+ scanCodeWords(content, headers, nonCode, words);
1992
+ // Insertion order is the expansions' and the text's: deterministic.
1993
+ return [group.startLine, group.endLine, length, [...words].join(' ')];
1994
+ });
1658
1995
  }
1659
1996
 
1660
1997
  /** Whether the derived calls of a file pending derivation can bear `name`. */
@@ -1665,8 +2002,56 @@ function rustPendingMayBear(fileEntry, name) {
1665
2002
  if (typeof rec.words !== 'string') return true;
1666
2003
  let words = pendingWordSets.get(rec);
1667
2004
  if (!words) pendingWordSets.set(rec, words = new Set(rec.words.split(' ')));
1668
- // A raw identifier's token is `r#name`; source text holds both parts.
1669
- return words.has(name) || words.has(String(name).replace(/^r#/, '')) || words.has(`r#${name}`);
2005
+ return hasWord(words, name);
2006
+ }
2007
+
2008
+ /**
2009
+ * Whether a recorded group's word list (space-separated) holds `name`, as
2010
+ * hasWord does for a set (fix #405b: one name is asked per group, so the
2011
+ * list is searched rather than split).
2012
+ */
2013
+ function groupHasWord(list, name) {
2014
+ const text = String(name);
2015
+ const bare = text.replace(/^r#/, '');
2016
+ return listHasWord(list, text) || (bare !== text && listHasWord(list, bare)) || listHasWord(list, `r#${text}`);
2017
+ }
2018
+
2019
+ function listHasWord(list, word) {
2020
+ if (!word) return false;
2021
+ for (let at = list.indexOf(word); at >= 0; at = list.indexOf(word, at + 1)) {
2022
+ const end = at + word.length;
2023
+ if ((at === 0 || list.charCodeAt(at - 1) === 32) && (end === list.length || list.charCodeAt(end) === 32)) return true;
2024
+ }
2025
+ return false;
2026
+ }
2027
+
2028
+ const pendingFileLists = new WeakMap(); // operation content cache -> pending file paths
2029
+
2030
+ /**
2031
+ * Files whose macro expansions' calls are not derived yet and whose
2032
+ * expanded text can hold one of `names` (fix #405). The callee index lists
2033
+ * such a file under its parsed records' names and its expansions'
2034
+ * callee-position names, but a derived record can bear any word of the
2035
+ * expanded text (`self.view` passed as a value, `Self { .. }` named by its
2036
+ * impl): a query visits these files too, so the files it reads do not
2037
+ * depend on which files earlier queries derived.
2038
+ */
2039
+ function rustPendingFilesBearing(index, names) {
2040
+ const scope = index._opContentCache;
2041
+ let files = scope ? pendingFileLists.get(scope) : undefined;
2042
+ if (!files) {
2043
+ files = [];
2044
+ for (const [filePath, fileEntry] of index.files) {
2045
+ if (fileEntry.rustMacroExpansion?.callsPending) files.push(filePath);
2046
+ }
2047
+ if (scope) pendingFileLists.set(scope, files);
2048
+ }
2049
+ const bearing = [];
2050
+ for (const filePath of files) {
2051
+ const fileEntry = index.files.get(filePath);
2052
+ if (names.some(name => rustPendingMayBear(fileEntry, name))) bearing.push(filePath);
2053
+ }
2054
+ return bearing;
1670
2055
  }
1671
2056
 
1672
2057
  function tokenText(tokens) {
@@ -1824,7 +2209,10 @@ function applyRustMacroExpansion(index, pending, options = {}, started = undefin
1824
2209
  ...(rec.blind.length > 0 && { blind: rec.blind.sort((a, b) => a.line - b.line ||
1825
2210
  codeUnitCompare(a.macro, b.macro) || codeUnitCompare(a.reason, b.reason)) }),
1826
2211
  ...(rec.expanded && { expanded: true }),
1827
- ...(rec.callsPending && { callsPending: true, calleeNames: rec.calleeNames || [], words: rec.words }),
2212
+ ...(rec.callsPending && {
2213
+ callsPending: true, calleeNames: rec.calleeNames || [], words: rec.words, expansions: rec.expansions,
2214
+ ...(rec.groups && { groups: rec.groups }),
2215
+ }),
1828
2216
  };
1829
2217
  }
1830
2218
  return touched || false;
@@ -1978,7 +2366,10 @@ function expandForWorker(state, filePath) {
1978
2366
  sites: rec.sites, blind: rec.blind, expanded: rec.expanded,
1979
2367
  usedNames: [...rec.used.values()].map(def => def.name),
1980
2368
  ...(rec.symbols && { symbols: rec.symbols }),
1981
- ...(rec.callsPending && { callsPending: true, calleeNames: rec.calleeNames, words: rec.words }),
2369
+ ...(rec.callsPending && {
2370
+ callsPending: true, calleeNames: rec.calleeNames, words: rec.words, expansions: rec.expansions,
2371
+ ...(rec.groups && { groups: rec.groups }),
2372
+ }),
1982
2373
  };
1983
2374
  }
1984
2375
 
@@ -2051,20 +2442,538 @@ function rustExpandedCalls(index, filePath, rawCalls, { materialize = true } = {
2051
2442
  if (!rec?.callsPending || !Array.isArray(rawCalls)) return rawCalls;
2052
2443
  const hints = (rec.calleeNames || []).map(name => ({ name, line: 0, calleeHint: true }));
2053
2444
  if (!materialize) return hints.length > 0 ? rawCalls.concat(hints) : rawCalls;
2054
- if (!index._rustMacroState) index._rustMacroState = createState(index);
2445
+ const ctx = pendingContext(index, filePath, { whole: true });
2055
2446
  if (index.calleeIndex && hints.length > 0) index._removeFromCalleeIndex(filePath, hints);
2056
2447
  rec.callsPending = false;
2057
2448
  try {
2058
- expandFile(index._rustMacroState, filePath, 'calls');
2449
+ if (ctx) composeCalls(index, ctx);
2059
2450
  } catch (error) {
2060
2451
  rec.callsPending = true;
2061
2452
  throw error;
2062
2453
  }
2454
+ index._rustMacroState.contexts.delete(filePath);
2063
2455
  delete rec.words;
2456
+ delete rec.groups;
2457
+ delete rec.expansions;
2064
2458
  index.macroExpansionDirty = true;
2065
2459
  return index.callsCache.get(filePath)?.calls || rawCalls;
2066
2460
  }
2067
2461
 
2462
+ // Derivation contexts whose trees are kept (fix #405); a context dropped
2463
+ // earlier keeps the records of the groups it read.
2464
+ const LIVE_CONTEXTS = 3;
2465
+
2466
+ /**
2467
+ * The calls-pass context of a file whose expansion calls are pending, or
2468
+ * null when nothing of it expands (its parsed records stand).
2469
+ */
2470
+ function pendingContext(index, filePath, { whole = false } = {}) {
2471
+ if (!index._rustMacroState) index._rustMacroState = createState(index);
2472
+ const state = index._rustMacroState;
2473
+ const contexts = state.contexts;
2474
+ let ctx = contexts.get(filePath);
2475
+ if (ctx === null) return null;
2476
+ if (ctx && ctx.live) {
2477
+ contexts.delete(filePath);
2478
+ contexts.set(filePath, ctx);
2479
+ return ctx;
2480
+ }
2481
+ // A file derived whole before any part of it was read is read in one
2482
+ // pass (fix #405b): the groups apart cost the same expansions and parse
2483
+ // plus a second plan of the file's invocations.
2484
+ const fresh = whole && ctx === undefined
2485
+ ? expandFile(state, filePath, 'calls')?.context || null
2486
+ : buildContext(state, filePath);
2487
+ if (fresh && ctx) {
2488
+ // Rebuilt: the groups the dropped context read keep their records.
2489
+ if (fresh.groups.length !== ctx.groups.length || !!fresh.isolated !== !!ctx.isolated) {
2490
+ throw new Error(`Rust macro expansion context changed: ${filePath}`);
2491
+ }
2492
+ for (let i = 0; i < fresh.groups.length; i++) {
2493
+ const kept = ctx.groups[i];
2494
+ if (!kept.records) continue;
2495
+ Object.assign(fresh.groups[i], { records: kept.records, units: kept.units, starts: kept.starts });
2496
+ }
2497
+ fresh.invocations = ctx.invocations;
2498
+ fresh.seen = ctx.seen;
2499
+ }
2500
+ contexts.delete(filePath);
2501
+ contexts.set(filePath, fresh);
2502
+ if (fresh) {
2503
+ let live = 0;
2504
+ for (const [file, other] of [...contexts].reverse()) {
2505
+ if (!other?.live) continue;
2506
+ if (++live <= LIVE_CONTEXTS || file === filePath) continue;
2507
+ contexts.set(file, releaseContext(other));
2508
+ }
2509
+ }
2510
+ return fresh;
2511
+ }
2512
+
2513
+ /**
2514
+ * A file's calls-pass context: its groups read apart when the build's
2515
+ * record of its expansions still matches its invocations, else one pass
2516
+ * over the whole file. null when nothing of it expands.
2517
+ */
2518
+ function buildContext(state, filePath) {
2519
+ const rec = state.index.files.get(filePath)?.rustMacroExpansion;
2520
+ // Expansions the build could not parse in context are read in one pass
2521
+ // (whose retries can also drop a neighbor).
2522
+ if (Array.isArray(rec?.expansions) && !(rec.blind || []).some(site => site.reason === 'parse-error')) {
2523
+ const isolated = isolatedContext(state, filePath, rec.expansions);
2524
+ if (isolated !== undefined) return isolated;
2525
+ }
2526
+ return expandFile(state, filePath, 'calls')?.context || null;
2527
+ }
2528
+
2529
+ /** What a dropped context keeps: the records it read. */
2530
+ function releaseContext(ctx) {
2531
+ return {
2532
+ isolated: ctx.isolated,
2533
+ groups: ctx.groups.map(group => ({ records: group.records, units: group.units, starts: group.starts })),
2534
+ invocations: ctx.invocations,
2535
+ seen: ctx.seen,
2536
+ live: false,
2537
+ };
2538
+ }
2539
+
2540
+ /**
2541
+ * A context that reads each group apart (fix #405). The build recorded, for
2542
+ * every invocation it expanded, its hygiene start and its expanded length:
2543
+ * a group expands alone with the numbering and the offsets of a pass over
2544
+ * the whole file, every other expansion standing in as a blank of its
2545
+ * length. undefined when that record no longer matches the file's
2546
+ * invocations or the file has parse errors (one pass reads it), null when
2547
+ * nothing of it expands.
2548
+ */
2549
+ function isolatedContext(state, filePath, expansions) {
2550
+ const index = state.index;
2551
+ const content = readContent(state, filePath);
2552
+ if (!index.files.get(filePath) || content === null) return undefined;
2553
+ if (!fileMayExpand(state, filePath)) return null;
2554
+ const tree = safeParse(state.parser, content, undefined, PARSE_OPTIONS);
2555
+ if (tree.rootNode.hasError) return undefined;
2556
+ const plan = plannedInvocations(state, filePath, content, tree, { blind: [] });
2557
+ if (plan.length * 3 !== expansions.length) return undefined;
2558
+ const slots = [];
2559
+ for (let i = 0; i < plan.length; i++) {
2560
+ const item = plan[i];
2561
+ if (item.node.startIndex !== expansions[3 * i]) return undefined;
2562
+ item.hygiene = expansions[3 * i + 1];
2563
+ item.length = expansions[3 * i + 2];
2564
+ if (item.length < 0) continue;
2565
+ slots.push({
2566
+ item, node: item.node, start: item.node.startIndex, end: item.node.endIndex,
2567
+ startLine: item.line, endLine: item.node.endPosition.row + 1,
2568
+ });
2569
+ }
2570
+ if (slots.length === 0) return null;
2571
+ slots.sort((a, b) => a.start - b.start);
2572
+ const { units } = analysisUnits(slots, 'calls');
2573
+ const lookups = unitLookups(units);
2574
+ const groups = groupsOf(units);
2575
+ for (const group of groups) group.slots = [];
2576
+ for (const slot of slots) groups[lookups.unitAtOffset(slot.start).group].slots.push(slot);
2577
+ return {
2578
+ isolated: true, filePath, content, tree, slots, groups,
2579
+ original: index.callsCache.get(filePath)?.calls || [],
2580
+ seen: new Set(), invocations: new Map(), parser: state.parser, state, live: true,
2581
+ };
2582
+ }
2583
+
2584
+ /** Units sharing a line form one group; units get their group's index. */
2585
+ function groupsOf(units) {
2586
+ const groups = [];
2587
+ for (const unit of units) {
2588
+ const last = groups[groups.length - 1];
2589
+ if (last && unit.startLine <= last.endLine) {
2590
+ last.units.push(unit);
2591
+ if (unit.endLine > last.endLine) last.endLine = unit.endLine;
2592
+ } else {
2593
+ groups.push({ units: [unit], startLine: unit.startLine, endLine: unit.endLine, records: null });
2594
+ }
2595
+ }
2596
+ for (let i = 0; i < groups.length; i++) for (const unit of groups[i].units) unit.group = i;
2597
+ return groups;
2598
+ }
2599
+
2600
+ /**
2601
+ * Read groups of an isolated context. Groups whose expanded text parses
2602
+ * without an error read together as alone (each unit is an item of its
2603
+ * container); otherwise each group is read alone.
2604
+ */
2605
+ function readIsolatedGroups(ctx, targets) {
2606
+ if (targets.length === 1) {
2607
+ readIsolatedGroup(ctx, targets[0]);
2608
+ return;
2609
+ }
2610
+ const rec = { blind: [], used: new Map() };
2611
+ const wanted = new Set(targets);
2612
+ const mine = new Set(targets.flatMap(i => ctx.groups[i].slots));
2613
+ const expand = slot => expandSlot(ctx, slot, rec);
2614
+ const live = ctx.slots.filter(slot => mine.has(slot)).map(expand);
2615
+ const keep = analysisUnits(live, 'calls').keep;
2616
+ const extras = ctx.slots.filter(slot => !mine.has(slot) &&
2617
+ keep.some(([start, end]) => slot.start < end && slot.end > start)).map(expand);
2618
+ const expanded = live.concat(extras);
2619
+ const bySlot = new Map(expanded.map(region => [region.slot, region]));
2620
+ const all = ctx.slots.map(slot => bySlot.get(slot) || placeholderOf(ctx, slot));
2621
+ const built = buildSynthetic(ctx.content, all, analysisUnits(expanded, 'calls').keep);
2622
+ const synthTree = parseSynthetic(ctx.parser, built.text, built.spans);
2623
+ if (collectErrorRanges(synthTree.rootNode, []).length > 0) {
2624
+ for (const i of targets) readIsolatedGroup(ctx, i);
2625
+ return;
2626
+ }
2627
+ const units = [];
2628
+ for (const i of targets) {
2629
+ const group = ctx.groups[i];
2630
+ const regions = live.filter(region => region.slot && ctx.groups[i].slots.includes(region.slot));
2631
+ group.units = analysisUnits(regions, 'calls').units;
2632
+ for (const unit of group.units) unit.group = i;
2633
+ group.starts = regions.map(region => region.start);
2634
+ group.records = [];
2635
+ units.push(...group.units);
2636
+ }
2637
+ units.sort((a, b) => a.start - b.start);
2638
+ const lookups = unitLookups(units);
2639
+ const originalSpanless = new Map();
2640
+ for (const record of ctx.original) {
2641
+ if (Number.isFinite(record.callStart) || !lookups.unitAtLine(record.line)) continue;
2642
+ const key = spanlessKey(record);
2643
+ originalSpanless.set(key, (originalSpanless.get(key) || 0) + 1);
2644
+ }
2645
+ const rust = require('../languages/rust');
2646
+ const synthCalls = rust.findCallsInCode(built.text, ctx.parser, {
2647
+ tree: synthTree, itemRecovery: rust.rustExpandedItemRecovery(built.text, ctx.parser, synthTree),
2648
+ });
2649
+ mergeRecords(ctx, synthCalls, {
2650
+ mapper: offsetMapper(all), lookups, regions: live, synthTree, originalSpanless,
2651
+ accept: unit => wanted.has(unit.group), push: (unit, call) => ctx.groups[unit.group].records.push(call),
2652
+ });
2653
+ }
2654
+
2655
+ /** Expand one invocation of an isolated context as the build did. */
2656
+ function expandSlot(ctx, slot, rec) {
2657
+ ctx.state.hygieneSeq = slot.item.hygiene;
2658
+ const region = expandPlanned(ctx.state, ctx.filePath, ctx.content, slot.item, rec);
2659
+ if (!region || region.layout.text.length !== slot.item.length) {
2660
+ throw new Error(`Rust macro expansion at ${ctx.filePath}:${slot.startLine} differs from the build's`);
2661
+ }
2662
+ region.slot = slot;
2663
+ return region;
2664
+ }
2665
+
2666
+ /** A blank of an expansion's length standing in for it (its newlines kept). */
2667
+ function placeholderOf(ctx, slot) {
2668
+ if (!ctx.placeholders) ctx.placeholders = new Map();
2669
+ let region = ctx.placeholders.get(slot);
2670
+ if (!region) {
2671
+ const breaks = slot.endLine - slot.startLine;
2672
+ region = {
2673
+ start: slot.start, end: slot.end, startLine: slot.startLine, endLine: slot.endLine, blank: true,
2674
+ layout: { text: ' '.repeat(slot.item.length - breaks) + '\n'.repeat(breaks), marks: [] },
2675
+ };
2676
+ ctx.placeholders.set(slot, region);
2677
+ }
2678
+ // Its offsets are set by the synthetic it stands in.
2679
+ return { ...region };
2680
+ }
2681
+
2682
+ /** Read one group of an isolated context: its expansions, parsed alone. */
2683
+ function readIsolatedGroup(ctx, gi) {
2684
+ const group = ctx.groups[gi];
2685
+ const rec = { blind: [], used: new Map() };
2686
+ const expand = slot => expandSlot(ctx, slot, rec);
2687
+ const mine = new Set(group.slots);
2688
+ let live = group.slots.map(expand);
2689
+ // Expansions inside the kept headers of this group's units
2690
+ // (`impl T for m!() { .. }`) are part of its text.
2691
+ const headerKeep = analysisUnits(live, 'calls').keep;
2692
+ let extras = ctx.slots.filter(slot => !mine.has(slot) &&
2693
+ headerKeep.some(([start, end]) => slot.start < end && slot.end > start)).map(expand);
2694
+ let synthetic = null;
2695
+ let synthTree = null;
2696
+ let all = null;
2697
+ for (let attempt = 0; live.length > 0 && attempt <= ctx.slots.length; attempt++) {
2698
+ const expanded = live.concat(extras);
2699
+ const bySlot = new Map(expanded.map(region => [region.slot, region]));
2700
+ const dropped = new Set(group.slots.filter(slot => !bySlot.has(slot)));
2701
+ // A dropped expansion stays as written, as in one pass.
2702
+ all = ctx.slots.filter(slot => !dropped.has(slot)).map(slot => bySlot.get(slot) || placeholderOf(ctx, slot));
2703
+ const built = buildSynthetic(ctx.content, all, analysisUnits(expanded, 'calls').keep);
2704
+ synthetic = built.text;
2705
+ synthTree = parseSynthetic(ctx.parser, synthetic, built.spans);
2706
+ const errors = collectErrorRanges(synthTree.rootNode, []);
2707
+ const bad = new Set();
2708
+ for (const [start, end] of errors) {
2709
+ for (const region of expanded) {
2710
+ if (start < region.synthEnd && end > region.synthStart) bad.add(region);
2711
+ else if (start === end && start >= region.synthStart && start <= region.synthEnd) bad.add(region);
2712
+ }
2713
+ }
2714
+ if (bad.size === 0) break;
2715
+ live = live.filter(region => !bad.has(region));
2716
+ extras = extras.filter(region => !bad.has(region));
2717
+ }
2718
+ group.units = live.length > 0 ? analysisUnits(live, 'calls').units : [];
2719
+ group.starts = live.map(region => region.start);
2720
+ group.records = [];
2721
+ if (live.length === 0) return;
2722
+ const lookups = unitLookups(group.units);
2723
+ const mapper = offsetMapper(all);
2724
+ const originalSpanless = new Map();
2725
+ for (const record of ctx.original) {
2726
+ if (Number.isFinite(record.callStart) || !lookups.unitAtLine(record.line)) continue;
2727
+ const key = spanlessKey(record);
2728
+ originalSpanless.set(key, (originalSpanless.get(key) || 0) + 1);
2729
+ }
2730
+ const rust = require('../languages/rust');
2731
+ const synthCalls = rust.findCallsInCode(synthetic, ctx.parser, {
2732
+ tree: synthTree, itemRecovery: rust.rustExpandedItemRecovery(synthetic, ctx.parser, synthTree),
2733
+ });
2734
+ mergeRecords(ctx, synthCalls, {
2735
+ mapper, lookups, regions: live, synthTree, originalSpanless,
2736
+ accept: () => true, push: (unit, call) => group.records.push(call),
2737
+ });
2738
+ }
2739
+
2740
+ /**
2741
+ * Words the records of an isolated group can bear: its lines and its units'
2742
+ * kept headers in the file (literals and comments left out), the text of
2743
+ * every project macro its expansions can transcribe, and the crate names
2744
+ * `$crate` becomes.
2745
+ */
2746
+ function isolatedGroupWords(ctx, group) {
2747
+ if (group.words) return group.words;
2748
+ const state = ctx.state;
2749
+ const words = new Set();
2750
+ const ranges = analysisUnits(group.slots, 'calls').keep.concat([[
2751
+ lineOffset(state, ctx.filePath, group.startLine), lineEndOffset(state, ctx.filePath, group.endLine),
2752
+ ]]).sort((a, b) => a[0] - b[0]);
2753
+ if (!ctx.nonCode) ctx.nonCode = ctx.tree.rootNode.descendantsOfType(NON_CODE_TOKENS);
2754
+ const nonCode = ctx.nonCode;
2755
+ const scan = (start, end) => {
2756
+ if (end <= start) return;
2757
+ for (const word of ctx.content.slice(start, end).match(WORD) || []) words.add(word);
2758
+ };
2759
+ let next = 0;
2760
+ let done = 0;
2761
+ for (const [rangeStart, end] of ranges) {
2762
+ let at = Math.max(rangeStart, done);
2763
+ if (end <= at) continue;
2764
+ while (next < nonCode.length && nonCode[next].endIndex <= at) next++;
2765
+ for (let i = next; i < nonCode.length; i++) {
2766
+ const token = nonCode[i];
2767
+ if (token.startIndex >= end) break;
2768
+ scan(at, Math.min(token.startIndex, end));
2769
+ at = Math.max(at, token.endIndex);
2770
+ }
2771
+ scan(at, end);
2772
+ done = end;
2773
+ }
2774
+ for (const word of [...words]) if (state.inventory.has(word)) addTemplateWords(state, word, words);
2775
+ words.add('crate');
2776
+ group.words = words;
2777
+ return words;
2778
+ }
2779
+
2780
+ /** Words of every definition of macro `name` and the macros they name, transitively. */
2781
+ function addTemplateWords(state, name, words) {
2782
+ if (!state.templateWords) state.templateWords = new Map();
2783
+ let own = state.templateWords.get(name);
2784
+ if (!own) {
2785
+ own = new Set();
2786
+ state.templateWords.set(name, own);
2787
+ const pending = [name];
2788
+ const visited = new Set();
2789
+ while (pending.length > 0) {
2790
+ const next = pending.pop();
2791
+ if (visited.has(next)) continue;
2792
+ visited.add(next);
2793
+ for (const def of state.inventory.get(next) || []) {
2794
+ const content = readContent(state, def.file);
2795
+ if (content === null) continue;
2796
+ const text = content.slice(lineOffset(state, def.file, def.startLine), lineOffset(state, def.file, def.endLine + 1));
2797
+ for (const word of text.match(WORD) || []) {
2798
+ own.add(word);
2799
+ if (state.inventory.has(word) && !visited.has(word)) pending.push(word);
2800
+ }
2801
+ const crate = packageOf(state, def.file)?.packageName;
2802
+ if (crate) own.add(crate);
2803
+ }
2804
+ }
2805
+ }
2806
+ for (const word of own) words.add(word);
2807
+ }
2808
+
2809
+ /**
2810
+ * The records of a file whose expansion calls are pending that a consumer
2811
+ * reading only part of them needs (fix #405), in the order the composed
2812
+ * calls hold them and as the same record objects. `name`: the records
2813
+ * `keep` accepts, each of which must bear the name as one of its own
2814
+ * identifier tokens (its name or written receiver); `lines`: [start, end],
2815
+ * the records on those lines; `lineSet`: the records on any of those lines. Only the groups that can hold such records
2816
+ * are read. undefined when the file's calls are not pending, `name` is not
2817
+ * an identifier, or every group is read (the file's calls are then derived
2818
+ * whole by the caller).
2819
+ */
2820
+ const IDENTIFIER = /^(?:r#)?[\p{L}_][\p{L}\p{N}_]*$/u;
2821
+ // Tenths of a file's unread expanded text from which a view derives the file
2822
+ // whole (fix #405b).
2823
+ const WHOLE_FILE_SHARE = 9;
2824
+
2825
+ function rustPendingRecords(index, filePath, { name = null, keep = null, lines = null, lineSet = null } = {}) {
2826
+ const entry = index.files.get(filePath);
2827
+ if (!entry?.rustMacroExpansion?.callsPending) return undefined;
2828
+ // Hygiene renames locals a transcriber introduces (`x__ucn_h3`): no
2829
+ // source text holds such a name.
2830
+ if (name !== null && (!IDENTIFIER.test(name) || /__ucn_h\d+$/.test(name))) return undefined;
2831
+ const raw = index.callsCache.get(filePath)?.calls;
2832
+ if (!Array.isArray(raw)) return undefined;
2833
+ const matches = record => (!lines || (record.line >= lines[0] && record.line <= lines[1])) &&
2834
+ (!lineSet || lineSet.has(record.line)) && (!keep || keep(record));
2835
+ // No derived record bears a name outside the file's words (#375): the
2836
+ // parsed records then answer, unless one of them bears it.
2837
+ if (name !== null && !rustPendingMayBear(entry, name) && !raw.some(matches)) return [];
2838
+ // The groups recorded at build (fix #405b) are chosen before anything is
2839
+ // parsed: a group can hold a record `matches` accepts when a word its
2840
+ // expansions output or its headers hold is the name, or when a parsed
2841
+ // record on its lines is accepted (the units' own code is written there).
2842
+ const rec = entry.rustMacroExpansion;
2843
+ const recorded = Array.isArray(rec.groups) ? rec.groups : null;
2844
+ let planned = null;
2845
+ if (recorded) {
2846
+ const read = index._rustMacroState?.contexts?.get(filePath)?.groups || null;
2847
+ const parsedOn = name !== null ? parsedRecordLines(raw, matches) : null;
2848
+ planned = [];
2849
+ let wantedLength = 0;
2850
+ let unreadLength = 0;
2851
+ for (let i = 0; i < recorded.length; i++) {
2852
+ const [startLine, endLine, length] = recorded[i];
2853
+ const unread = !read?.[i]?.records;
2854
+ if (unread) unreadLength += length;
2855
+ if (lines && (endLine < lines[0] || startLine > lines[1])) continue;
2856
+ if (lineSet && ![...lineSet].some(line => line >= startLine && line <= endLine)) continue;
2857
+ if (name !== null && !groupHasWord(recorded[i][3], name) &&
2858
+ !parsedOn.some(line => line >= startLine && line <= endLine)) continue;
2859
+ planned.push(i);
2860
+ if (unread) wantedLength += length;
2861
+ }
2862
+ if (planned.length === 0) {
2863
+ // Every group read: the caller derives the file's calls whole.
2864
+ if (read && read.length > 0 && read.every(group => group.records)) return undefined;
2865
+ // No group can hold one: the accepted records are parsed ones
2866
+ // outside every unit, as the composed calls hold them.
2867
+ return raw.filter(matches).sort((a, b) => a.line - b.line);
2868
+ }
2869
+ // Reading most of the expanded text that is still unread costs more
2870
+ // than deriving the file whole (groups read together cost ~5-10%
2871
+ // over one pass), which then answers every later query of the file:
2872
+ // a cost decision, not a semantic one - either way the records are
2873
+ // the composed calls'.
2874
+ if (wantedLength > 0 && wantedLength * 10 >= unreadLength * WHOLE_FILE_SHARE) return undefined;
2875
+ }
2876
+ const ctx = pendingContext(index, filePath);
2877
+ if (!ctx) return raw.filter(matches);
2878
+ // Every group read: the caller derives the file's calls whole.
2879
+ if (ctx.groups.every(group => group.records)) return undefined;
2880
+ let wanted;
2881
+ if (planned && ctx.groups.length === recorded.length && ctx.groups.every((group, i) =>
2882
+ group.startLine === recorded[i][0] && group.endLine === recorded[i][1])) {
2883
+ wanted = planned;
2884
+ } else {
2885
+ wanted = [];
2886
+ for (let i = 0; i < ctx.groups.length; i++) {
2887
+ const group = ctx.groups[i];
2888
+ if (lines && (group.endLine < lines[0] || group.startLine > lines[1])) continue;
2889
+ if (lineSet && ![...lineSet].some(line => line >= group.startLine && line <= group.endLine)) continue;
2890
+ if (name !== null && !groupMayBear(ctx, group, name)) continue;
2891
+ wanted.push(i);
2892
+ }
2893
+ }
2894
+ readGroups(ctx, wanted);
2895
+ if (ctx.groups.every(group => group.records)) return undefined;
2896
+ const out = ctx.original.filter(record => matches(record) && !inReadUnit(ctx, record));
2897
+ for (const i of wanted) for (const record of ctx.groups[i].records) if (matches(record)) out.push(record);
2898
+ for (const record of invocationRecords(ctx)) if (matches(record)) out.push(record);
2899
+ return out.sort((a, b) => a.line - b.line);
2900
+ }
2901
+
2902
+ /**
2903
+ * Whether a group's records can bear `name`: a word of the group's lines in
2904
+ * the expanded file or of the kept text outside every unit (headers: a
2905
+ * `Self` record bears its impl's type name).
2906
+ */
2907
+ function groupMayBear(ctx, group, name) {
2908
+ if (ctx.isolated) return hasWord(isolatedGroupWords(ctx, group), name);
2909
+ if (!ctx.outsideWords) {
2910
+ const words = new Set();
2911
+ let at = 0;
2912
+ for (const unit of ctx.units) {
2913
+ for (const word of ctx.synthetic.slice(at, unit.synthStart).match(WORD) || []) words.add(word);
2914
+ at = Math.max(at, unit.synthEnd);
2915
+ }
2916
+ for (const word of ctx.synthetic.slice(at).match(WORD) || []) words.add(word);
2917
+ ctx.outsideWords = words;
2918
+ }
2919
+ if (hasWord(ctx.outsideWords, name)) return true;
2920
+ if (!group.words) {
2921
+ if (!ctx.synthLineStarts) {
2922
+ const starts = [0];
2923
+ const text = ctx.synthetic;
2924
+ for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
2925
+ ctx.synthLineStarts = starts;
2926
+ }
2927
+ const starts = ctx.synthLineStarts;
2928
+ const from = starts[Math.max(group.startLine - 1, 0)] ?? ctx.synthetic.length;
2929
+ const to = starts[group.endLine] ?? ctx.synthetic.length;
2930
+ group.words = new Set(ctx.synthetic.slice(from, to).match(WORD) || []);
2931
+ }
2932
+ return hasWord(group.words, name);
2933
+ }
2934
+
2935
+ /** Lines of the parsed records `matches` accepts. */
2936
+ function parsedRecordLines(raw, matches) {
2937
+ const out = [];
2938
+ for (const record of raw) if (matches(record)) out.push(record.line);
2939
+ return out;
2940
+ }
2941
+
2942
+ function hasWord(words, name) {
2943
+ // A raw identifier's token is `r#name`; text holds both parts.
2944
+ return words.has(name) || words.has(String(name).replace(/^r#/, '')) || words.has(`r#${name}`);
2945
+ }
2946
+
2947
+ /**
2948
+ * Relative paths of the files whose expansion calls are pending with no
2949
+ * part derived yet, sorted.
2950
+ */
2951
+ function rustUntouchedPendingFiles(index) {
2952
+ const contexts = index._rustMacroState?.contexts;
2953
+ const out = [];
2954
+ for (const [filePath, entry] of index.files) {
2955
+ if (!entry.rustMacroExpansion?.callsPending) continue;
2956
+ if (contexts?.get(filePath)?.groups?.some(group => group.records)) continue;
2957
+ out.push(entry.relativePath);
2958
+ }
2959
+ return out.sort(codeUnitCompare);
2960
+ }
2961
+
2962
+ /** Derive whole every file whose expansion calls were derived in part. */
2963
+ function completeRustMacroCalls(index) {
2964
+ const contexts = index._rustMacroState?.contexts;
2965
+ if (!contexts || contexts.size === 0) return 0;
2966
+ let done = 0;
2967
+ for (const filePath of [...contexts.keys()].sort(codeUnitCompare)) {
2968
+ if (!index.files.get(filePath)?.rustMacroExpansion?.callsPending) continue;
2969
+ const cached = index.callsCache.get(filePath);
2970
+ if (!cached) continue;
2971
+ rustExpandedCalls(index, filePath, cached.calls);
2972
+ done++;
2973
+ }
2974
+ return done;
2975
+ }
2976
+
2068
2977
  /** Derive every pending file's expansion calls (whole-program consumers). */
2069
2978
  function materializeRustMacroCalls(index) {
2070
2979
  for (const [filePath, entry] of index.files) {
@@ -2110,4 +3019,8 @@ module.exports = {
2110
3019
  materializeRustMacroCalls,
2111
3020
  rustMacroExpansionSummary,
2112
3021
  rustPendingMayBear,
3022
+ rustPendingFilesBearing,
3023
+ rustPendingRecords,
3024
+ completeRustMacroCalls,
3025
+ rustUntouchedPendingFiles,
2113
3026
  };