ucn 5.5.0 → 5.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -9
  2. package/.claude/skills/ucn/references/commands.md +38 -16
  3. package/.claude/skills/ucn/references/resolution-rules.md +126 -20
  4. package/.claude/skills/ucn/references/trust-contract.md +5 -3
  5. package/README.md +41 -27
  6. package/cli/index.js +6 -1
  7. package/core/accessors.js +341 -54
  8. package/core/account.js +106 -31
  9. package/core/analysis.js +266 -127
  10. package/core/ast-analysis.js +12 -3
  11. package/core/brief.js +8 -4
  12. package/core/build-worker.js +2 -1
  13. package/core/cache.js +144 -6
  14. package/core/call-name-cache.js +134 -0
  15. package/core/callable-scope.js +21 -0
  16. package/core/callers.js +5111 -1024
  17. package/core/check.js +22 -5
  18. package/core/class-identity.js +399 -12
  19. package/core/constructor-calls.js +831 -0
  20. package/core/constructor-hierarchy.js +147 -0
  21. package/core/constructor-protocol.js +196 -0
  22. package/core/contract-membership.js +346 -140
  23. package/core/cpp-conversion-constraints.js +123 -0
  24. package/core/cpp-conversion-syntax.js +216 -0
  25. package/core/cpp-conversions.js +414 -0
  26. package/core/cpp-scope.js +245 -44
  27. package/core/deadcode.js +35 -15
  28. package/core/decorator-binding.js +137 -3
  29. package/core/enum-reference-syntax.js +134 -0
  30. package/core/execute.js +17 -1
  31. package/core/external-macros.js +42 -10
  32. package/core/go-packages.js +171 -0
  33. package/core/graph-build.js +2 -0
  34. package/core/graph.js +75 -59
  35. package/core/ignore-rules.js +2 -1
  36. package/core/imports.js +145 -19
  37. package/core/index-ir.js +41 -4
  38. package/core/ir.js +27 -6
  39. package/core/macro-expansion.js +38 -2
  40. package/core/out-of-class-members.js +182 -0
  41. package/core/output/analysis-ext.js +4 -2
  42. package/core/output/analysis.js +89 -20
  43. package/core/output/graph.js +12 -3
  44. package/core/output/lines.js +4 -3
  45. package/core/output/public.js +1 -0
  46. package/core/output/reporting.js +6 -1
  47. package/core/output/shared.js +2 -0
  48. package/core/output/tracing.js +28 -22
  49. package/core/output-budget.js +2 -2
  50. package/core/parallel-build.js +52 -1
  51. package/core/project.js +228 -114
  52. package/core/provenance-constructor-base.js +161 -0
  53. package/core/provenance-facts.js +257 -27
  54. package/core/provenance-overload.js +458 -54
  55. package/core/provenance-type.js +487 -0
  56. package/core/provenance.js +234 -15
  57. package/core/public-command.js +17 -2
  58. package/core/query-bodies.js +159 -0
  59. package/core/reflection.js +255 -2
  60. package/core/rename-token-syntax.js +111 -0
  61. package/core/reporting.js +13 -7
  62. package/core/rust-macro-expansion.js +1202 -157
  63. package/core/rust-modules.js +21 -3
  64. package/core/rust-names.js +607 -0
  65. package/core/search.js +70 -17
  66. package/core/shared.js +98 -1
  67. package/core/source-memo.js +201 -0
  68. package/core/text-usage-syntax.js +38 -0
  69. package/core/tracing.js +7 -1
  70. package/core/type-denotation.js +157 -46
  71. package/core/type-references.js +615 -246
  72. package/core/value-references.js +2777 -0
  73. package/core/verify.js +1097 -281
  74. package/languages/adapter.js +28 -4
  75. package/languages/c-family.js +1118 -164
  76. package/languages/constructor-bases.js +105 -0
  77. package/languages/cpp-platform.js +26 -0
  78. package/languages/cpp-site-syntax.js +83 -0
  79. package/languages/csharp-scope.js +439 -0
  80. package/languages/csharp.js +428 -68
  81. package/languages/go.js +385 -207
  82. package/languages/html.js +4 -2
  83. package/languages/index.js +397 -2
  84. package/languages/java-platform.js +226 -0
  85. package/languages/java-unicode.js +269 -0
  86. package/languages/java.js +461 -117
  87. package/languages/javascript.js +418 -66
  88. package/languages/lexical-scope.js +190 -23
  89. package/languages/python.js +404 -54
  90. package/languages/rust.js +548 -89
  91. package/languages/utils.js +67 -8
  92. package/mcp/server.js +8 -2
  93. package/package.json +2 -2
@@ -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,84 @@ 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);
1483
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);
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
+ const calls = composedCalls(ctx);
1726
+ installComposedCalls(index, ctx.filePath, calls, ctx.original);
1727
+ return calls;
1728
+ }
1729
+
1730
+ /** The calls a derivation context composes, stable by line (not installed). */
1731
+ function composedCalls(ctx) {
1732
+ readGroups(ctx, ctx.groups.map((group, i) => i));
1733
+ const calls = ctx.original.filter(record => !inReadUnit(ctx, record));
1734
+ for (const group of ctx.groups) for (const record of group.records) calls.push(record);
1735
+ for (const record of invocationRecords(ctx)) calls.push(record);
1736
+ // Stable by line: records keep document order within a line.
1737
+ calls.sort((a, b) => a.line - b.line);
1738
+ return calls;
1739
+ }
1740
+
1741
+ /** Install a file's composed calls in the calls cache, with the facts the file entry derives from them. */
1742
+ function installComposedCalls(index, filePath, calls, original) {
1743
+ const fileEntry = index.files.get(filePath);
1484
1744
  // Shapes of generated calls whose value may be lost (audit-async).
1485
1745
  const { openCallShape } = require('../languages/rust-value-flow');
1486
1746
  const shapes = calls.filter(call => call.macroExpansion && call.name && !call.inMacro && !call.valueConsumed)
@@ -1488,8 +1748,7 @@ function expandFile(state, filePath, mode = 'symbols') {
1488
1748
  if (shapes.length > 0) {
1489
1749
  fileEntry.openCalls = [...new Set([...(fileEntry.openCalls || []), ...shapes])].sort(codeUnitCompare);
1490
1750
  }
1491
- // Stable by line: records keep document order within a line.
1492
- calls.sort((a, b) => a.line - b.line);
1751
+ const cached = index.callsCache.get(filePath);
1493
1752
  if (cached) {
1494
1753
  if (index.calleeIndex) index._removeFromCalleeIndex(filePath, original);
1495
1754
  cached.calls = calls;
@@ -1498,8 +1757,6 @@ function expandFile(state, filePath, mode = 'symbols') {
1498
1757
  index.callsCache.set(filePath, { mtime: fileEntry.mtime, hash: fileEntry.hash, calls });
1499
1758
  }
1500
1759
  index.callsCacheDirty = true;
1501
- rec.expanded = true;
1502
- return rec;
1503
1760
  }
1504
1761
 
1505
1762
  const CALL_NODES = new Set(['call_expression', 'macro_invocation', 'struct_expression', 'generic_function']);
@@ -1621,11 +1878,12 @@ function callableNames(regions) {
1621
1878
  for (const region of regions) {
1622
1879
  const marks = region.layout.marks;
1623
1880
  for (let i = 0; i < marks.length - 1; i++) {
1881
+ const next = marks[i + 1].leaf.v;
1882
+ if (!(next === '(' || next === '!' || next === '{' ||
1883
+ (next === '::' && marks[i + 2]?.leaf.v === '<'))) continue;
1624
1884
  const leaf = marks[i].leaf;
1625
1885
  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);
1886
+ names.add(leaf.v);
1629
1887
  }
1630
1888
  }
1631
1889
  return [...names].sort(codeUnitCompare);
@@ -1634,27 +1892,117 @@ function callableNames(regions) {
1634
1892
  const WORD = /[\p{L}\p{N}_]+/gu;
1635
1893
  const WHOLE_WORD = /^[\p{L}\p{N}_]+$/u;
1636
1894
 
1895
+ // Tokens whose text is never an identifier: literals and comments.
1896
+ const NON_CODE_TOKENS = ['string_literal', 'raw_string_literal', 'char_literal', 'line_comment', 'block_comment'];
1897
+
1637
1898
  /**
1638
1899
  * The words (identifier-like runs) of the text the calls pass analyzes for
1639
1900
  * these regions: the kept source around them (units and their headers) and
1640
1901
  * every expansion's tokens. A call record derived from that text can bear no
1641
1902
  * 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.
1903
+ * for a name among them (fix #375). A record's name and written receiver are
1904
+ * identifier tokens, so the text of literals and comments is left out (fix
1905
+ * #405: `"--count"` and `// the count` made a `count` query derive four
1906
+ * test files); kept ranges are whole items and headers, which the calls
1907
+ * pass lexes as the file's own parse did. A file with parse errors is read
1908
+ * whole. Space-separated, sorted.
1643
1909
  */
1644
- function expansionWords(content, regions) {
1910
+ function expansionWords(content, regions, nonCode = [], outputs = regions.map(outputWordsOf)) {
1645
1911
  const words = new Set();
1646
- for (const [start, end] of analysisUnits(regions, 'calls').keep) {
1912
+ scanCodeWords(content, analysisUnits(regions, 'calls').keep, nonCode, words);
1913
+ for (const output of outputs) for (const word of output) words.add(word);
1914
+ return [...words].sort(codeUnitCompare).join(' ');
1915
+ }
1916
+
1917
+ /** Words of `ranges` (sorted, disjoint) of the source, literals and comments (`nonCode`, sorted) left out. */
1918
+ function scanCodeWords(content, ranges, nonCode, words) {
1919
+ const scan = (start, end) => {
1920
+ if (end <= start) return;
1647
1921
  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);
1922
+ };
1923
+ let next = 0;
1924
+ for (const [start, end] of ranges) {
1925
+ let at = start;
1926
+ while (next < nonCode.length && nonCode[next].endIndex <= at) next++;
1927
+ for (let i = next; i < nonCode.length; i++) {
1928
+ const token = nonCode[i];
1929
+ if (token.startIndex >= end) break;
1930
+ scan(at, Math.min(token.startIndex, end));
1931
+ at = Math.max(at, token.endIndex);
1655
1932
  }
1933
+ scan(at, end);
1656
1934
  }
1657
- return [...words].sort(codeUnitCompare).join(' ');
1935
+ }
1936
+
1937
+ /** The identifier tokens an expansion outputs (a non-ASCII identifier is a 'punct' leaf), once each. */
1938
+ function outputWordsOf(region) {
1939
+ const words = new Set();
1940
+ for (const mark of region.layout.marks) {
1941
+ const leaf = mark.leaf;
1942
+ if (leaf.k === 'ident' || (leaf.k === 'punct' && WHOLE_WORD.test(leaf.v))) words.add(leaf.v);
1943
+ }
1944
+ return [...words];
1945
+ }
1946
+
1947
+ /**
1948
+ * The groups of units the calls pass reads apart (fix #405b), each as
1949
+ * [startLine, endLine, expanded length, words]: the identifiers its
1950
+ * expansions output (with those of any expansion in its kept headers) and
1951
+ * the code of its kept headers outside its units (`Self { .. }` names the
1952
+ * impl's type). A record a group derives in the units' own code is written
1953
+ * there, as the file's parsed record on that line is: the calls pass
1954
+ * consults those for the rest (rustPendingRecords). Expanded length: the
1955
+ * text the group's own expansions add, for the cost of reading it.
1956
+ */
1957
+ function groupRecords(content, regions, nonCode, outputs) {
1958
+ const { units } = analysisUnits(regions, 'calls');
1959
+ const groups = groupsOf(units);
1960
+ const lookups = unitLookups(units);
1961
+ const members = groups.map(() => []);
1962
+ const groupOf = regions.map((region, i) => {
1963
+ const group = lookups.unitAtOffset(region.start).group;
1964
+ members[group].push(i);
1965
+ return group;
1966
+ });
1967
+ // Expansions are disjoint and sorted: the first one ending after `at`.
1968
+ const firstEndingAfter = at => {
1969
+ let lo = 0;
1970
+ let hi = regions.length;
1971
+ while (lo < hi) {
1972
+ const mid = (lo + hi) >> 1;
1973
+ if (regions[mid].end <= at) lo = mid + 1; else hi = mid;
1974
+ }
1975
+ return lo;
1976
+ };
1977
+ return groups.map((group, gi) => {
1978
+ const keep = analysisUnits(members[gi].map(i => regions[i]), 'calls').keep;
1979
+ const words = new Set();
1980
+ let length = 0;
1981
+ for (const i of members[gi]) {
1982
+ length += regions[i].layout.text.length;
1983
+ for (const word of outputs[i]) words.add(word);
1984
+ }
1985
+ // Expansions inside its kept headers (`impl T for m!() {..}`).
1986
+ for (const [start, end] of keep) {
1987
+ for (let i = firstEndingAfter(start); i < regions.length && regions[i].start < end; i++) {
1988
+ if (groupOf[i] !== gi) for (const word of outputs[i]) words.add(word);
1989
+ }
1990
+ }
1991
+ // Header code: the kept ranges outside the group's units.
1992
+ const headers = [];
1993
+ for (const [start, end] of keep) {
1994
+ let at = start;
1995
+ for (const unit of group.units) {
1996
+ if (unit.end <= at || unit.start >= end) continue;
1997
+ if (unit.start > at) headers.push([at, unit.start]);
1998
+ at = Math.max(at, unit.end);
1999
+ }
2000
+ if (at < end) headers.push([at, end]);
2001
+ }
2002
+ scanCodeWords(content, headers, nonCode, words);
2003
+ // Insertion order is the expansions' and the text's: deterministic.
2004
+ return [group.startLine, group.endLine, length, [...words].join(' ')];
2005
+ });
1658
2006
  }
1659
2007
 
1660
2008
  /** Whether the derived calls of a file pending derivation can bear `name`. */
@@ -1665,8 +2013,56 @@ function rustPendingMayBear(fileEntry, name) {
1665
2013
  if (typeof rec.words !== 'string') return true;
1666
2014
  let words = pendingWordSets.get(rec);
1667
2015
  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}`);
2016
+ return hasWord(words, name);
2017
+ }
2018
+
2019
+ /**
2020
+ * Whether a recorded group's word list (space-separated) holds `name`, as
2021
+ * hasWord does for a set (fix #405b: one name is asked per group, so the
2022
+ * list is searched rather than split).
2023
+ */
2024
+ function groupHasWord(list, name) {
2025
+ const text = String(name);
2026
+ const bare = text.replace(/^r#/, '');
2027
+ return listHasWord(list, text) || (bare !== text && listHasWord(list, bare)) || listHasWord(list, `r#${text}`);
2028
+ }
2029
+
2030
+ function listHasWord(list, word) {
2031
+ if (!word) return false;
2032
+ for (let at = list.indexOf(word); at >= 0; at = list.indexOf(word, at + 1)) {
2033
+ const end = at + word.length;
2034
+ if ((at === 0 || list.charCodeAt(at - 1) === 32) && (end === list.length || list.charCodeAt(end) === 32)) return true;
2035
+ }
2036
+ return false;
2037
+ }
2038
+
2039
+ const pendingFileLists = new WeakMap(); // operation content cache -> pending file paths
2040
+
2041
+ /**
2042
+ * Files whose macro expansions' calls are not derived yet and whose
2043
+ * expanded text can hold one of `names` (fix #405). The callee index lists
2044
+ * such a file under its parsed records' names and its expansions'
2045
+ * callee-position names, but a derived record can bear any word of the
2046
+ * expanded text (`self.view` passed as a value, `Self { .. }` named by its
2047
+ * impl): a query visits these files too, so the files it reads do not
2048
+ * depend on which files earlier queries derived.
2049
+ */
2050
+ function rustPendingFilesBearing(index, names) {
2051
+ const scope = index._opContentCache;
2052
+ let files = scope ? pendingFileLists.get(scope) : undefined;
2053
+ if (!files) {
2054
+ files = [];
2055
+ for (const [filePath, fileEntry] of index.files) {
2056
+ if (fileEntry.rustMacroExpansion?.callsPending) files.push(filePath);
2057
+ }
2058
+ if (scope) pendingFileLists.set(scope, files);
2059
+ }
2060
+ const bearing = [];
2061
+ for (const filePath of files) {
2062
+ const fileEntry = index.files.get(filePath);
2063
+ if (names.some(name => rustPendingMayBear(fileEntry, name))) bearing.push(filePath);
2064
+ }
2065
+ return bearing;
1670
2066
  }
1671
2067
 
1672
2068
  function tokenText(tokens) {
@@ -1771,11 +2167,62 @@ function startRustMacroExpansion(index, pending, options = {}) {
1771
2167
  const files = invokingFiles(index, pending, inventory, names);
1772
2168
  let bytes = 0;
1773
2169
  for (const file of files) bytes += index.files.get(file)?.size || 0;
1774
- const run = files.length >= PARALLEL_MIN_FILES && bytes >= PARALLEL_MIN_BYTES
1775
- ? startWorkers(index, files, { ...options, inventory }) : null;
2170
+ // A sequential build has no parse workers to keep (fix #422): when the
2171
+ // planned expansion is large - a crate whose API macros generate most of
2172
+ // its code has little source to parse - fresh workers expand it.
2173
+ let pool = options.pool?.available.length > 0 ? options.pool : null;
2174
+ let ownPool = null;
2175
+ if (!pool && freshExpansionWorthwhile(index, files, inventory)) {
2176
+ ownPool = require('./parallel-build').spawnExpansionPool(index,
2177
+ freshWorkerCount(index, Math.ceil(files.length / FRESH_FILES_PER_WORKER), files.length));
2178
+ pool = ownPool;
2179
+ }
2180
+ const run = files.length >= PARALLEL_MIN_FILES && (bytes >= PARALLEL_MIN_BYTES || ownPool)
2181
+ ? startWorkers(index, files, { ...options, pool, inventory }) : null;
2182
+ if (ownPool) {
2183
+ if (run) {
2184
+ const dispose = run.dispose;
2185
+ run.dispose = () => { dispose(); ownPool.dispose(); };
2186
+ } else {
2187
+ ownPool.dispose();
2188
+ }
2189
+ }
1776
2190
  return { inventory, files, names, results: run };
1777
2191
  }
1778
2192
 
2193
+ // Fresh expansion workers start cold (module load, parser warm-up): they
2194
+ // pay for themselves from this much planned expansion.
2195
+ const FRESH_MIN_SITES = 48;
2196
+ const FRESH_MIN_FILES = 12;
2197
+ const FRESH_FILES_PER_WORKER = 6;
2198
+
2199
+ /**
2200
+ * How many fresh threads to start for `wanted` workers' worth of work over
2201
+ * `files` files: 2 to EXPANSION_WORKERS, never more than an explicit build
2202
+ * worker count (a request for that execution shape) and none when the build
2203
+ * disabled workers; 0 when fewer than two would run.
2204
+ */
2205
+ function freshWorkerCount(index, wanted, files) {
2206
+ const limit = index.lastBuildWorkerLimit ?? EXPANSION_WORKERS;
2207
+ const count = Math.min(EXPANSION_WORKERS, limit, files, Math.max(2, wanted));
2208
+ return count >= 2 ? count : 0;
2209
+ }
2210
+
2211
+ /** Is the planned expansion of a build without kept parse workers worth fresh workers? */
2212
+ function freshExpansionWorthwhile(index, files, inventory) {
2213
+ const env = process.env.UCN_FRESH_EXPANSION;
2214
+ if (env === '0' || freshWorkerCount(index, 2, files.length) === 0) return false;
2215
+ if (env === '1') return true;
2216
+ if (files.length < FRESH_MIN_FILES) return false;
2217
+ let sites = 0;
2218
+ for (const file of files) {
2219
+ for (const call of index.callsCache.get(file)?.calls || []) {
2220
+ if (call.isMacro && inventory.has(call.name)) sites++;
2221
+ }
2222
+ }
2223
+ return sites >= FRESH_MIN_SITES;
2224
+ }
2225
+
1779
2226
  /**
1780
2227
  * Build-time pass, phase 2b (after the import graph resolved `mod`
1781
2228
  * declarations): apply the expansions of the planned files, expanding in
@@ -1824,7 +2271,10 @@ function applyRustMacroExpansion(index, pending, options = {}, started = undefin
1824
2271
  ...(rec.blind.length > 0 && { blind: rec.blind.sort((a, b) => a.line - b.line ||
1825
2272
  codeUnitCompare(a.macro, b.macro) || codeUnitCompare(a.reason, b.reason)) }),
1826
2273
  ...(rec.expanded && { expanded: true }),
1827
- ...(rec.callsPending && { callsPending: true, calleeNames: rec.calleeNames || [], words: rec.words }),
2274
+ ...(rec.callsPending && {
2275
+ callsPending: true, calleeNames: rec.calleeNames || [], words: rec.words, expansions: rec.expansions,
2276
+ ...(rec.groups && { groups: rec.groups }),
2277
+ }),
1828
2278
  };
1829
2279
  }
1830
2280
  return touched || false;
@@ -1847,7 +2297,7 @@ const EXPANSION_WORKERS = 3;
1847
2297
  * to expand their import facts, impl/trait spans and macro invocation
1848
2298
  * records. Also every name a project callable bears.
1849
2299
  */
1850
- function workerSnapshot(index, workload, inventory) {
2300
+ function workerSnapshot(index, workload, inventory, fullCalls = false) {
1851
2301
  const expanding = new Set(workload);
1852
2302
  // Invocations resolve at the invoking file, and nested ones also at the
1853
2303
  // defining file.
@@ -1878,7 +2328,10 @@ function workerSnapshot(index, workload, inventory) {
1878
2328
  record.importBindings = (entry.importBindings || [])
1879
2329
  .filter(binding => inventory.has(binding.alias || binding.name));
1880
2330
  }
1881
- if (full) {
2331
+ if (full && fullCalls) {
2332
+ // A calls derivation reads every parsed record of the file.
2333
+ record.calls = index.callsCache.get(file)?.calls || [];
2334
+ } else if (full) {
1882
2335
  record.calls = (index.callsCache.get(file)?.calls || [])
1883
2336
  .filter(call => call.isMacro && !call.macroExpansion && inventory.has(call.name))
1884
2337
  .map(call => ({
@@ -1949,7 +2402,7 @@ function snapshotIndex(snapshot) {
1949
2402
  * `signal[queueIndex]`), posting each record to `port`; `signal[workerIndex]`
1950
2403
  * is set when this worker is done.
1951
2404
  */
1952
- function runExpansionQueue({ snapshot, files, signal, workerIndex, queueIndex }, port) {
2405
+ async function runExpansionQueue({ snapshot, files, signal, workerIndex, queueIndex, mode }, port) {
1953
2406
  const signalArray = new Int32Array(signal);
1954
2407
  try {
1955
2408
  const state = createState(snapshotIndex(JSON.parse(Buffer.from(snapshot).toString('utf8'))));
@@ -1959,11 +2412,15 @@ function runExpansionQueue({ snapshot, files, signal, workerIndex, queueIndex },
1959
2412
  const file = files[next];
1960
2413
  let result;
1961
2414
  try {
1962
- result = { file, record: expandForWorker(state, file) };
2415
+ result = { file, record: mode === 'calls' ? deriveForWorker(state, file) : expandForWorker(state, file) };
1963
2416
  } catch (error) {
1964
2417
  result = { file, error: error.message };
1965
2418
  }
1966
2419
  port.postMessage([result]);
2420
+ // Yield between files, as the parse loop does (fix #365): the
2421
+ // native trees of an expansion are freed only when this thread
2422
+ // returns to its event loop (fix #422).
2423
+ await new Promise(resolve => setImmediate(resolve));
1967
2424
  }
1968
2425
  } finally {
1969
2426
  Atomics.store(signalArray, workerIndex, 1);
@@ -1971,6 +2428,12 @@ function runExpansionQueue({ snapshot, files, signal, workerIndex, queueIndex },
1971
2428
  }
1972
2429
  }
1973
2430
 
2431
+ /** Worker-side: the whole-file calls derivation of one file (fix #422). */
2432
+ function deriveForWorker(state, filePath) {
2433
+ const ctx = expandFile(state, filePath, 'calls')?.context || null;
2434
+ return ctx ? { calls: composedCalls(ctx) } : { none: true };
2435
+ }
2436
+
1974
2437
  /** Worker-side: symbols-mode expansion of one file, as plain records. */
1975
2438
  function expandForWorker(state, filePath) {
1976
2439
  const rec = expandFile(state, filePath) || { sites: 0, blind: [], used: new Map(), expanded: false };
@@ -1978,7 +2441,10 @@ function expandForWorker(state, filePath) {
1978
2441
  sites: rec.sites, blind: rec.blind, expanded: rec.expanded,
1979
2442
  usedNames: [...rec.used.values()].map(def => def.name),
1980
2443
  ...(rec.symbols && { symbols: rec.symbols }),
1981
- ...(rec.callsPending && { callsPending: true, calleeNames: rec.calleeNames, words: rec.words }),
2444
+ ...(rec.callsPending && {
2445
+ callsPending: true, calleeNames: rec.calleeNames, words: rec.words, expansions: rec.expansions,
2446
+ ...(rec.groups && { groups: rec.groups }),
2447
+ }),
1982
2448
  };
1983
2449
  }
1984
2450
 
@@ -2000,7 +2466,8 @@ function startWorkers(index, files, options = {}) {
2000
2466
  codeUnitCompare(a, b));
2001
2467
  // One serialization shared by every worker (a structured clone per
2002
2468
  // worker cost more than the expansion on large workspaces).
2003
- const encoded = Buffer.from(JSON.stringify(workerSnapshot(index, files, options.inventory)), 'utf8');
2469
+ const encoded = Buffer.from(JSON.stringify(workerSnapshot(index, files, options.inventory,
2470
+ options.mode === 'calls')), 'utf8');
2004
2471
  const snapshot = new SharedArrayBuffer(encoded.length);
2005
2472
  encoded.copy(Buffer.from(snapshot));
2006
2473
  const sab = new SharedArrayBuffer(4 * (count + 1));
@@ -2009,7 +2476,8 @@ function startWorkers(index, files, options = {}) {
2009
2476
  for (let i = 0; i < count; i++) {
2010
2477
  const worker = pool.available[i];
2011
2478
  ports.push(pool.ports[worker]);
2012
- pool.post(worker, { snapshot, files: ordered, signal: sab, workerIndex: i, queueIndex: count });
2479
+ pool.post(worker, { snapshot, files: ordered, signal: sab, workerIndex: i, queueIndex: count,
2480
+ ...(options.mode === 'calls' && { mode: 'calls' }) });
2013
2481
  }
2014
2482
  // Kept workers this job does not need end now.
2015
2483
  for (const worker of pool.available.slice(count)) pool.retire(worker);
@@ -2051,29 +2519,602 @@ function rustExpandedCalls(index, filePath, rawCalls, { materialize = true } = {
2051
2519
  if (!rec?.callsPending || !Array.isArray(rawCalls)) return rawCalls;
2052
2520
  const hints = (rec.calleeNames || []).map(name => ({ name, line: 0, calleeHint: true }));
2053
2521
  if (!materialize) return hints.length > 0 ? rawCalls.concat(hints) : rawCalls;
2054
- if (!index._rustMacroState) index._rustMacroState = createState(index);
2522
+ const ctx = pendingContext(index, filePath, { whole: true });
2055
2523
  if (index.calleeIndex && hints.length > 0) index._removeFromCalleeIndex(filePath, hints);
2056
2524
  rec.callsPending = false;
2057
2525
  try {
2058
- expandFile(index._rustMacroState, filePath, 'calls');
2526
+ if (ctx) composeCalls(index, ctx);
2059
2527
  } catch (error) {
2060
2528
  rec.callsPending = true;
2061
2529
  throw error;
2062
2530
  }
2531
+ finishDerivation(index, filePath, rec);
2532
+ return index.callsCache.get(filePath)?.calls || rawCalls;
2533
+ }
2534
+
2535
+ function finishDerivation(index, filePath, rec) {
2536
+ index._rustMacroState?.contexts.delete(filePath);
2063
2537
  delete rec.words;
2538
+ delete rec.groups;
2539
+ delete rec.expansions;
2064
2540
  index.macroExpansionDirty = true;
2065
- return index.callsCache.get(filePath)?.calls || rawCalls;
2541
+ }
2542
+
2543
+ // Derivation contexts whose trees are kept (fix #405); a context dropped
2544
+ // earlier keeps the records of the groups it read.
2545
+ const LIVE_CONTEXTS = 3;
2546
+
2547
+ /**
2548
+ * The calls-pass context of a file whose expansion calls are pending, or
2549
+ * null when nothing of it expands (its parsed records stand).
2550
+ */
2551
+ function pendingContext(index, filePath, { whole = false } = {}) {
2552
+ if (!index._rustMacroState) index._rustMacroState = createState(index);
2553
+ const state = index._rustMacroState;
2554
+ const contexts = state.contexts;
2555
+ let ctx = contexts.get(filePath);
2556
+ if (ctx === null) return null;
2557
+ if (ctx && ctx.live) {
2558
+ contexts.delete(filePath);
2559
+ contexts.set(filePath, ctx);
2560
+ return ctx;
2561
+ }
2562
+ // A file derived whole before any part of it was read is read in one
2563
+ // pass (fix #405b): the groups apart cost the same expansions and parse
2564
+ // plus a second plan of the file's invocations.
2565
+ const fresh = whole && ctx === undefined
2566
+ ? expandFile(state, filePath, 'calls')?.context || null
2567
+ : buildContext(state, filePath);
2568
+ if (fresh && ctx) {
2569
+ // Rebuilt: the groups the dropped context read keep their records.
2570
+ if (fresh.groups.length !== ctx.groups.length || !!fresh.isolated !== !!ctx.isolated) {
2571
+ throw new Error(`Rust macro expansion context changed: ${filePath}`);
2572
+ }
2573
+ for (let i = 0; i < fresh.groups.length; i++) {
2574
+ const kept = ctx.groups[i];
2575
+ if (!kept.records) continue;
2576
+ Object.assign(fresh.groups[i], { records: kept.records, units: kept.units, starts: kept.starts });
2577
+ }
2578
+ fresh.invocations = ctx.invocations;
2579
+ fresh.seen = ctx.seen;
2580
+ }
2581
+ contexts.delete(filePath);
2582
+ contexts.set(filePath, fresh);
2583
+ if (fresh) {
2584
+ let live = 0;
2585
+ for (const [file, other] of [...contexts].reverse()) {
2586
+ if (!other?.live) continue;
2587
+ if (++live <= LIVE_CONTEXTS || file === filePath) continue;
2588
+ contexts.set(file, releaseContext(other));
2589
+ }
2590
+ }
2591
+ return fresh;
2592
+ }
2593
+
2594
+ /**
2595
+ * A file's calls-pass context: its groups read apart when the build's
2596
+ * record of its expansions still matches its invocations, else one pass
2597
+ * over the whole file. null when nothing of it expands.
2598
+ */
2599
+ function buildContext(state, filePath) {
2600
+ const rec = state.index.files.get(filePath)?.rustMacroExpansion;
2601
+ // Expansions the build could not parse in context are read in one pass
2602
+ // (whose retries can also drop a neighbor).
2603
+ if (Array.isArray(rec?.expansions) && !(rec.blind || []).some(site => site.reason === 'parse-error')) {
2604
+ const isolated = isolatedContext(state, filePath, rec.expansions);
2605
+ if (isolated !== undefined) return isolated;
2606
+ }
2607
+ return expandFile(state, filePath, 'calls')?.context || null;
2608
+ }
2609
+
2610
+ /** What a dropped context keeps: the records it read. */
2611
+ function releaseContext(ctx) {
2612
+ return {
2613
+ isolated: ctx.isolated,
2614
+ groups: ctx.groups.map(group => ({ records: group.records, units: group.units, starts: group.starts })),
2615
+ invocations: ctx.invocations,
2616
+ seen: ctx.seen,
2617
+ live: false,
2618
+ };
2619
+ }
2620
+
2621
+ /**
2622
+ * A context that reads each group apart (fix #405). The build recorded, for
2623
+ * every invocation it expanded, its hygiene start and its expanded length:
2624
+ * a group expands alone with the numbering and the offsets of a pass over
2625
+ * the whole file, every other expansion standing in as a blank of its
2626
+ * length. undefined when that record no longer matches the file's
2627
+ * invocations or the file has parse errors (one pass reads it), null when
2628
+ * nothing of it expands.
2629
+ */
2630
+ function isolatedContext(state, filePath, expansions) {
2631
+ const index = state.index;
2632
+ const content = readContent(state, filePath);
2633
+ if (!index.files.get(filePath) || content === null) return undefined;
2634
+ if (!fileMayExpand(state, filePath)) return null;
2635
+ const tree = safeParse(state.parser, content, undefined, PARSE_OPTIONS);
2636
+ if (tree.rootNode.hasError) return undefined;
2637
+ const plan = plannedInvocations(state, filePath, content, tree, { blind: [] });
2638
+ if (plan.length * 3 !== expansions.length) return undefined;
2639
+ const slots = [];
2640
+ for (let i = 0; i < plan.length; i++) {
2641
+ const item = plan[i];
2642
+ if (item.node.startIndex !== expansions[3 * i]) return undefined;
2643
+ item.hygiene = expansions[3 * i + 1];
2644
+ item.length = expansions[3 * i + 2];
2645
+ if (item.length < 0) continue;
2646
+ slots.push({
2647
+ item, node: item.node, start: item.node.startIndex, end: item.node.endIndex,
2648
+ startLine: item.line, endLine: item.node.endPosition.row + 1,
2649
+ });
2650
+ }
2651
+ if (slots.length === 0) return null;
2652
+ slots.sort((a, b) => a.start - b.start);
2653
+ const { units } = analysisUnits(slots, 'calls');
2654
+ const lookups = unitLookups(units);
2655
+ const groups = groupsOf(units);
2656
+ for (const group of groups) group.slots = [];
2657
+ for (const slot of slots) groups[lookups.unitAtOffset(slot.start).group].slots.push(slot);
2658
+ return {
2659
+ isolated: true, filePath, content, tree, slots, groups,
2660
+ original: index.callsCache.get(filePath)?.calls || [],
2661
+ seen: new Set(), invocations: new Map(), parser: state.parser, state, live: true,
2662
+ };
2663
+ }
2664
+
2665
+ /** Units sharing a line form one group; units get their group's index. */
2666
+ function groupsOf(units) {
2667
+ const groups = [];
2668
+ for (const unit of units) {
2669
+ const last = groups[groups.length - 1];
2670
+ if (last && unit.startLine <= last.endLine) {
2671
+ last.units.push(unit);
2672
+ if (unit.endLine > last.endLine) last.endLine = unit.endLine;
2673
+ } else {
2674
+ groups.push({ units: [unit], startLine: unit.startLine, endLine: unit.endLine, records: null });
2675
+ }
2676
+ }
2677
+ for (let i = 0; i < groups.length; i++) for (const unit of groups[i].units) unit.group = i;
2678
+ return groups;
2679
+ }
2680
+
2681
+ /**
2682
+ * Read groups of an isolated context. Groups whose expanded text parses
2683
+ * without an error read together as alone (each unit is an item of its
2684
+ * container); otherwise each group is read alone.
2685
+ */
2686
+ function readIsolatedGroups(ctx, targets) {
2687
+ if (targets.length === 1) {
2688
+ readIsolatedGroup(ctx, targets[0]);
2689
+ return;
2690
+ }
2691
+ const rec = { blind: [], used: new Map() };
2692
+ const wanted = new Set(targets);
2693
+ const mine = new Set(targets.flatMap(i => ctx.groups[i].slots));
2694
+ const expand = slot => expandSlot(ctx, slot, rec);
2695
+ const live = ctx.slots.filter(slot => mine.has(slot)).map(expand);
2696
+ const keep = analysisUnits(live, 'calls').keep;
2697
+ const extras = ctx.slots.filter(slot => !mine.has(slot) &&
2698
+ keep.some(([start, end]) => slot.start < end && slot.end > start)).map(expand);
2699
+ const expanded = live.concat(extras);
2700
+ const bySlot = new Map(expanded.map(region => [region.slot, region]));
2701
+ const all = ctx.slots.map(slot => bySlot.get(slot) || placeholderOf(ctx, slot));
2702
+ const built = buildSynthetic(ctx.content, all, analysisUnits(expanded, 'calls').keep);
2703
+ const synthTree = parseSynthetic(ctx.parser, built.text, built.spans);
2704
+ if (collectErrorRanges(synthTree.rootNode, []).length > 0) {
2705
+ for (const i of targets) readIsolatedGroup(ctx, i);
2706
+ return;
2707
+ }
2708
+ const units = [];
2709
+ for (const i of targets) {
2710
+ const group = ctx.groups[i];
2711
+ const regions = live.filter(region => region.slot && ctx.groups[i].slots.includes(region.slot));
2712
+ group.units = analysisUnits(regions, 'calls').units;
2713
+ for (const unit of group.units) unit.group = i;
2714
+ group.starts = regions.map(region => region.start);
2715
+ group.records = [];
2716
+ units.push(...group.units);
2717
+ }
2718
+ units.sort((a, b) => a.start - b.start);
2719
+ const lookups = unitLookups(units);
2720
+ const originalSpanless = new Map();
2721
+ for (const record of ctx.original) {
2722
+ if (Number.isFinite(record.callStart) || !lookups.unitAtLine(record.line)) continue;
2723
+ const key = spanlessKey(record);
2724
+ originalSpanless.set(key, (originalSpanless.get(key) || 0) + 1);
2725
+ }
2726
+ const rust = require('../languages/rust');
2727
+ const synthCalls = rust.findCallsInCode(built.text, ctx.parser, {
2728
+ tree: synthTree, itemRecovery: rust.rustExpandedItemRecovery(built.text, ctx.parser, synthTree),
2729
+ });
2730
+ mergeRecords(ctx, synthCalls, {
2731
+ mapper: offsetMapper(all), lookups, regions: live, synthTree, originalSpanless,
2732
+ accept: unit => wanted.has(unit.group), push: (unit, call) => ctx.groups[unit.group].records.push(call),
2733
+ });
2734
+ }
2735
+
2736
+ /** Expand one invocation of an isolated context as the build did. */
2737
+ function expandSlot(ctx, slot, rec) {
2738
+ ctx.state.hygieneSeq = slot.item.hygiene;
2739
+ const region = expandPlanned(ctx.state, ctx.filePath, ctx.content, slot.item, rec);
2740
+ if (!region || region.layout.text.length !== slot.item.length) {
2741
+ throw new Error(`Rust macro expansion at ${ctx.filePath}:${slot.startLine} differs from the build's`);
2742
+ }
2743
+ region.slot = slot;
2744
+ return region;
2745
+ }
2746
+
2747
+ /** A blank of an expansion's length standing in for it (its newlines kept). */
2748
+ function placeholderOf(ctx, slot) {
2749
+ if (!ctx.placeholders) ctx.placeholders = new Map();
2750
+ let region = ctx.placeholders.get(slot);
2751
+ if (!region) {
2752
+ const breaks = slot.endLine - slot.startLine;
2753
+ region = {
2754
+ start: slot.start, end: slot.end, startLine: slot.startLine, endLine: slot.endLine, blank: true,
2755
+ layout: { text: ' '.repeat(slot.item.length - breaks) + '\n'.repeat(breaks), marks: [] },
2756
+ };
2757
+ ctx.placeholders.set(slot, region);
2758
+ }
2759
+ // Its offsets are set by the synthetic it stands in.
2760
+ return { ...region };
2761
+ }
2762
+
2763
+ /** Read one group of an isolated context: its expansions, parsed alone. */
2764
+ function readIsolatedGroup(ctx, gi) {
2765
+ const group = ctx.groups[gi];
2766
+ const rec = { blind: [], used: new Map() };
2767
+ const expand = slot => expandSlot(ctx, slot, rec);
2768
+ const mine = new Set(group.slots);
2769
+ let live = group.slots.map(expand);
2770
+ // Expansions inside the kept headers of this group's units
2771
+ // (`impl T for m!() { .. }`) are part of its text.
2772
+ const headerKeep = analysisUnits(live, 'calls').keep;
2773
+ let extras = ctx.slots.filter(slot => !mine.has(slot) &&
2774
+ headerKeep.some(([start, end]) => slot.start < end && slot.end > start)).map(expand);
2775
+ let synthetic = null;
2776
+ let synthTree = null;
2777
+ let all = null;
2778
+ for (let attempt = 0; live.length > 0 && attempt <= ctx.slots.length; attempt++) {
2779
+ const expanded = live.concat(extras);
2780
+ const bySlot = new Map(expanded.map(region => [region.slot, region]));
2781
+ const dropped = new Set(group.slots.filter(slot => !bySlot.has(slot)));
2782
+ // A dropped expansion stays as written, as in one pass.
2783
+ all = ctx.slots.filter(slot => !dropped.has(slot)).map(slot => bySlot.get(slot) || placeholderOf(ctx, slot));
2784
+ const built = buildSynthetic(ctx.content, all, analysisUnits(expanded, 'calls').keep);
2785
+ synthetic = built.text;
2786
+ synthTree = parseSynthetic(ctx.parser, synthetic, built.spans);
2787
+ const errors = collectErrorRanges(synthTree.rootNode, []);
2788
+ const bad = new Set();
2789
+ for (const [start, end] of errors) {
2790
+ for (const region of expanded) {
2791
+ if (start < region.synthEnd && end > region.synthStart) bad.add(region);
2792
+ else if (start === end && start >= region.synthStart && start <= region.synthEnd) bad.add(region);
2793
+ }
2794
+ }
2795
+ if (bad.size === 0) break;
2796
+ live = live.filter(region => !bad.has(region));
2797
+ extras = extras.filter(region => !bad.has(region));
2798
+ }
2799
+ group.units = live.length > 0 ? analysisUnits(live, 'calls').units : [];
2800
+ group.starts = live.map(region => region.start);
2801
+ group.records = [];
2802
+ if (live.length === 0) return;
2803
+ const lookups = unitLookups(group.units);
2804
+ const mapper = offsetMapper(all);
2805
+ const originalSpanless = new Map();
2806
+ for (const record of ctx.original) {
2807
+ if (Number.isFinite(record.callStart) || !lookups.unitAtLine(record.line)) continue;
2808
+ const key = spanlessKey(record);
2809
+ originalSpanless.set(key, (originalSpanless.get(key) || 0) + 1);
2810
+ }
2811
+ const rust = require('../languages/rust');
2812
+ const synthCalls = rust.findCallsInCode(synthetic, ctx.parser, {
2813
+ tree: synthTree, itemRecovery: rust.rustExpandedItemRecovery(synthetic, ctx.parser, synthTree),
2814
+ });
2815
+ mergeRecords(ctx, synthCalls, {
2816
+ mapper, lookups, regions: live, synthTree, originalSpanless,
2817
+ accept: () => true, push: (unit, call) => group.records.push(call),
2818
+ });
2819
+ }
2820
+
2821
+ /**
2822
+ * Words the records of an isolated group can bear: its lines and its units'
2823
+ * kept headers in the file (literals and comments left out), the text of
2824
+ * every project macro its expansions can transcribe, and the crate names
2825
+ * `$crate` becomes.
2826
+ */
2827
+ function isolatedGroupWords(ctx, group) {
2828
+ if (group.words) return group.words;
2829
+ const state = ctx.state;
2830
+ const words = new Set();
2831
+ const ranges = analysisUnits(group.slots, 'calls').keep.concat([[
2832
+ lineOffset(state, ctx.filePath, group.startLine), lineEndOffset(state, ctx.filePath, group.endLine),
2833
+ ]]).sort((a, b) => a[0] - b[0]);
2834
+ if (!ctx.nonCode) ctx.nonCode = ctx.tree.rootNode.descendantsOfType(NON_CODE_TOKENS);
2835
+ const nonCode = ctx.nonCode;
2836
+ const scan = (start, end) => {
2837
+ if (end <= start) return;
2838
+ for (const word of ctx.content.slice(start, end).match(WORD) || []) words.add(word);
2839
+ };
2840
+ let next = 0;
2841
+ let done = 0;
2842
+ for (const [rangeStart, end] of ranges) {
2843
+ let at = Math.max(rangeStart, done);
2844
+ if (end <= at) continue;
2845
+ while (next < nonCode.length && nonCode[next].endIndex <= at) next++;
2846
+ for (let i = next; i < nonCode.length; i++) {
2847
+ const token = nonCode[i];
2848
+ if (token.startIndex >= end) break;
2849
+ scan(at, Math.min(token.startIndex, end));
2850
+ at = Math.max(at, token.endIndex);
2851
+ }
2852
+ scan(at, end);
2853
+ done = end;
2854
+ }
2855
+ for (const word of [...words]) if (state.inventory.has(word)) addTemplateWords(state, word, words);
2856
+ words.add('crate');
2857
+ group.words = words;
2858
+ return words;
2859
+ }
2860
+
2861
+ /** Words of every definition of macro `name` and the macros they name, transitively. */
2862
+ function addTemplateWords(state, name, words) {
2863
+ if (!state.templateWords) state.templateWords = new Map();
2864
+ let own = state.templateWords.get(name);
2865
+ if (!own) {
2866
+ own = new Set();
2867
+ state.templateWords.set(name, own);
2868
+ const pending = [name];
2869
+ const visited = new Set();
2870
+ while (pending.length > 0) {
2871
+ const next = pending.pop();
2872
+ if (visited.has(next)) continue;
2873
+ visited.add(next);
2874
+ for (const def of state.inventory.get(next) || []) {
2875
+ const content = readContent(state, def.file);
2876
+ if (content === null) continue;
2877
+ const text = content.slice(lineOffset(state, def.file, def.startLine), lineOffset(state, def.file, def.endLine + 1));
2878
+ for (const word of text.match(WORD) || []) {
2879
+ own.add(word);
2880
+ if (state.inventory.has(word) && !visited.has(word)) pending.push(word);
2881
+ }
2882
+ const crate = packageOf(state, def.file)?.packageName;
2883
+ if (crate) own.add(crate);
2884
+ }
2885
+ }
2886
+ }
2887
+ for (const word of own) words.add(word);
2888
+ }
2889
+
2890
+ /**
2891
+ * The records of a file whose expansion calls are pending that a consumer
2892
+ * reading only part of them needs (fix #405), in the order the composed
2893
+ * calls hold them and as the same record objects. `name`: the records
2894
+ * `keep` accepts, each of which must bear the name as one of its own
2895
+ * identifier tokens (its name or written receiver); `lines`: [start, end],
2896
+ * the records on those lines; `lineSet`: the records on any of those lines. Only the groups that can hold such records
2897
+ * are read. undefined when the file's calls are not pending, `name` is not
2898
+ * an identifier, or every group is read (the file's calls are then derived
2899
+ * whole by the caller).
2900
+ */
2901
+ const IDENTIFIER = /^(?:r#)?[\p{L}_][\p{L}\p{N}_]*$/u;
2902
+ // Tenths of a file's unread expanded text from which a view derives the file
2903
+ // whole (fix #405b).
2904
+ const WHOLE_FILE_SHARE = 9;
2905
+
2906
+ function rustPendingRecords(index, filePath, { name = null, keep = null, lines = null, lineSet = null } = {}) {
2907
+ const entry = index.files.get(filePath);
2908
+ if (!entry?.rustMacroExpansion?.callsPending) return undefined;
2909
+ // Hygiene renames locals a transcriber introduces (`x__ucn_h3`): no
2910
+ // source text holds such a name.
2911
+ if (name !== null && (!IDENTIFIER.test(name) || /__ucn_h\d+$/.test(name))) return undefined;
2912
+ const raw = index.callsCache.get(filePath)?.calls;
2913
+ if (!Array.isArray(raw)) return undefined;
2914
+ const matches = record => (!lines || (record.line >= lines[0] && record.line <= lines[1])) &&
2915
+ (!lineSet || lineSet.has(record.line)) && (!keep || keep(record));
2916
+ // No derived record bears a name outside the file's words (#375): the
2917
+ // parsed records then answer, unless one of them bears it.
2918
+ if (name !== null && !rustPendingMayBear(entry, name) && !raw.some(matches)) return [];
2919
+ // The groups recorded at build (fix #405b) are chosen before anything is
2920
+ // parsed: a group can hold a record `matches` accepts when a word its
2921
+ // expansions output or its headers hold is the name, or when a parsed
2922
+ // record on its lines is accepted (the units' own code is written there).
2923
+ const rec = entry.rustMacroExpansion;
2924
+ const recorded = Array.isArray(rec.groups) ? rec.groups : null;
2925
+ let planned = null;
2926
+ if (recorded) {
2927
+ const read = index._rustMacroState?.contexts?.get(filePath)?.groups || null;
2928
+ const parsedOn = name !== null ? parsedRecordLines(raw, matches) : null;
2929
+ planned = [];
2930
+ let wantedLength = 0;
2931
+ let unreadLength = 0;
2932
+ for (let i = 0; i < recorded.length; i++) {
2933
+ const [startLine, endLine, length] = recorded[i];
2934
+ const unread = !read?.[i]?.records;
2935
+ if (unread) unreadLength += length;
2936
+ if (lines && (endLine < lines[0] || startLine > lines[1])) continue;
2937
+ if (lineSet && ![...lineSet].some(line => line >= startLine && line <= endLine)) continue;
2938
+ if (name !== null && !groupHasWord(recorded[i][3], name) &&
2939
+ !parsedOn.some(line => line >= startLine && line <= endLine)) continue;
2940
+ planned.push(i);
2941
+ if (unread) wantedLength += length;
2942
+ }
2943
+ if (planned.length === 0) {
2944
+ // Every group read: the caller derives the file's calls whole.
2945
+ if (read && read.length > 0 && read.every(group => group.records)) return undefined;
2946
+ // No group can hold one: the accepted records are parsed ones
2947
+ // outside every unit, as the composed calls hold them.
2948
+ return raw.filter(matches).sort((a, b) => a.line - b.line);
2949
+ }
2950
+ // Reading most of the expanded text that is still unread costs more
2951
+ // than deriving the file whole (groups read together cost ~5-10%
2952
+ // over one pass), which then answers every later query of the file:
2953
+ // a cost decision, not a semantic one - either way the records are
2954
+ // the composed calls'.
2955
+ if (wantedLength > 0 && wantedLength * 10 >= unreadLength * WHOLE_FILE_SHARE) return undefined;
2956
+ }
2957
+ const ctx = pendingContext(index, filePath);
2958
+ if (!ctx) return raw.filter(matches);
2959
+ // Every group read: the caller derives the file's calls whole.
2960
+ if (ctx.groups.every(group => group.records)) return undefined;
2961
+ let wanted;
2962
+ if (planned && ctx.groups.length === recorded.length && ctx.groups.every((group, i) =>
2963
+ group.startLine === recorded[i][0] && group.endLine === recorded[i][1])) {
2964
+ wanted = planned;
2965
+ } else {
2966
+ wanted = [];
2967
+ for (let i = 0; i < ctx.groups.length; i++) {
2968
+ const group = ctx.groups[i];
2969
+ if (lines && (group.endLine < lines[0] || group.startLine > lines[1])) continue;
2970
+ if (lineSet && ![...lineSet].some(line => line >= group.startLine && line <= group.endLine)) continue;
2971
+ if (name !== null && !groupMayBear(ctx, group, name)) continue;
2972
+ wanted.push(i);
2973
+ }
2974
+ }
2975
+ readGroups(ctx, wanted);
2976
+ if (ctx.groups.every(group => group.records)) return undefined;
2977
+ const out = ctx.original.filter(record => matches(record) && !inReadUnit(ctx, record));
2978
+ for (const i of wanted) for (const record of ctx.groups[i].records) if (matches(record)) out.push(record);
2979
+ for (const record of invocationRecords(ctx)) if (matches(record)) out.push(record);
2980
+ return out.sort((a, b) => a.line - b.line);
2981
+ }
2982
+
2983
+ /**
2984
+ * Whether a group's records can bear `name`: a word of the group's lines in
2985
+ * the expanded file or of the kept text outside every unit (headers: a
2986
+ * `Self` record bears its impl's type name).
2987
+ */
2988
+ function groupMayBear(ctx, group, name) {
2989
+ if (ctx.isolated) return hasWord(isolatedGroupWords(ctx, group), name);
2990
+ if (!ctx.outsideWords) {
2991
+ const words = new Set();
2992
+ let at = 0;
2993
+ for (const unit of ctx.units) {
2994
+ for (const word of ctx.synthetic.slice(at, unit.synthStart).match(WORD) || []) words.add(word);
2995
+ at = Math.max(at, unit.synthEnd);
2996
+ }
2997
+ for (const word of ctx.synthetic.slice(at).match(WORD) || []) words.add(word);
2998
+ ctx.outsideWords = words;
2999
+ }
3000
+ if (hasWord(ctx.outsideWords, name)) return true;
3001
+ if (!group.words) {
3002
+ if (!ctx.synthLineStarts) {
3003
+ const starts = [0];
3004
+ const text = ctx.synthetic;
3005
+ for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
3006
+ ctx.synthLineStarts = starts;
3007
+ }
3008
+ const starts = ctx.synthLineStarts;
3009
+ const from = starts[Math.max(group.startLine - 1, 0)] ?? ctx.synthetic.length;
3010
+ const to = starts[group.endLine] ?? ctx.synthetic.length;
3011
+ group.words = new Set(ctx.synthetic.slice(from, to).match(WORD) || []);
3012
+ }
3013
+ return hasWord(group.words, name);
3014
+ }
3015
+
3016
+ /** Lines of the parsed records `matches` accepts. */
3017
+ function parsedRecordLines(raw, matches) {
3018
+ const out = [];
3019
+ for (const record of raw) if (matches(record)) out.push(record.line);
3020
+ return out;
3021
+ }
3022
+
3023
+ function hasWord(words, name) {
3024
+ // A raw identifier's token is `r#name`; text holds both parts.
3025
+ return words.has(name) || words.has(String(name).replace(/^r#/, '')) || words.has(`r#${name}`);
3026
+ }
3027
+
3028
+ /**
3029
+ * Relative paths of the files whose expansion calls are pending with no
3030
+ * part derived yet, sorted.
3031
+ */
3032
+ function rustUntouchedPendingFiles(index) {
3033
+ const contexts = index._rustMacroState?.contexts;
3034
+ const out = [];
3035
+ for (const [filePath, entry] of index.files) {
3036
+ if (!entry.rustMacroExpansion?.callsPending) continue;
3037
+ if (contexts?.get(filePath)?.groups?.some(group => group.records)) continue;
3038
+ out.push(entry.relativePath);
3039
+ }
3040
+ return out.sort(codeUnitCompare);
3041
+ }
3042
+
3043
+ /** Derive whole every file whose expansion calls were derived in part. */
3044
+ function completeRustMacroCalls(index) {
3045
+ const contexts = index._rustMacroState?.contexts;
3046
+ if (!contexts || contexts.size === 0) return 0;
3047
+ let done = 0;
3048
+ for (const filePath of [...contexts.keys()].sort(codeUnitCompare)) {
3049
+ if (!index.files.get(filePath)?.rustMacroExpansion?.callsPending) continue;
3050
+ const cached = index.callsCache.get(filePath);
3051
+ if (!cached) continue;
3052
+ rustExpandedCalls(index, filePath, cached.calls);
3053
+ done++;
3054
+ }
3055
+ return done;
2066
3056
  }
2067
3057
 
2068
3058
  /** Derive every pending file's expansion calls (whole-program consumers). */
2069
3059
  function materializeRustMacroCalls(index) {
3060
+ const pending = [];
3061
+ for (const [filePath, entry] of index.files) {
3062
+ if (!entry.rustMacroExpansion?.callsPending || !index.callsCache.get(filePath)) continue;
3063
+ // A file a query derived in part keeps its context's records.
3064
+ if (index._rustMacroState?.contexts.has(filePath)) continue;
3065
+ pending.push(filePath);
3066
+ }
3067
+ // Many files to derive at once (an orientation ranking, a deadcode
3068
+ // audit): fresh workers derive them (fix #422), each whole in one pass
3069
+ // exactly as below.
3070
+ const derived = deriveInWorkers(index, pending);
2070
3071
  for (const [filePath, entry] of index.files) {
2071
3072
  if (!entry.rustMacroExpansion?.callsPending) continue;
2072
3073
  const cached = index.callsCache.get(filePath);
2073
- if (cached) rustExpandedCalls(index, filePath, cached.calls);
3074
+ if (!cached) continue;
3075
+ const result = derived?.get(filePath);
3076
+ if (result) installDerived(index, filePath, entry.rustMacroExpansion, cached, result);
3077
+ else rustExpandedCalls(index, filePath, cached.calls);
2074
3078
  }
2075
3079
  }
2076
3080
 
3081
+ // Expanded text a materialization derives in fresh workers from, and per
3082
+ // worker (the build recorded each expansion's length): below it, starting
3083
+ // threads costs more than deriving in place.
3084
+ const DERIVE_IN_WORKERS_MIN_BYTES = 192 * 1024;
3085
+ const DERIVE_BYTES_PER_WORKER = 192 * 1024;
3086
+
3087
+ /** Whole-file calls derivations of `files` in fresh workers: Map<file, { calls } | { none }>, or null. */
3088
+ function deriveInWorkers(index, files) {
3089
+ const env = process.env.UCN_FRESH_EXPANSION;
3090
+ if (env === '0' || freshWorkerCount(index, 2, files.length) === 0) return null;
3091
+ let bytes = 0;
3092
+ for (const file of files) {
3093
+ const recorded = index.files.get(file)?.rustMacroExpansion?.expansions || [];
3094
+ for (let i = 2; i < recorded.length; i += 3) if (recorded[i] > 0) bytes += recorded[i];
3095
+ }
3096
+ if (env !== '1' && bytes < DERIVE_IN_WORKERS_MIN_BYTES) return null;
3097
+ const inventory = index._rustMacroState?.inventory || macroInventory(index);
3098
+ if (inventory.size === 0) return null;
3099
+ const count = freshWorkerCount(index, Math.ceil(bytes / DERIVE_BYTES_PER_WORKER), files.length);
3100
+ const pool = require('./parallel-build').spawnExpansionPool(index, count);
3101
+ if (!pool) return null;
3102
+ try {
3103
+ const run = startWorkers(index, files, { pool, inventory, mode: 'calls' });
3104
+ return run ? run.collect() : null;
3105
+ } finally {
3106
+ pool.dispose();
3107
+ }
3108
+ }
3109
+
3110
+ function installDerived(index, filePath, rec, cached, result) {
3111
+ const hints = (rec.calleeNames || []).map(name => ({ name, line: 0, calleeHint: true }));
3112
+ if (index.calleeIndex && hints.length > 0) index._removeFromCalleeIndex(filePath, hints);
3113
+ rec.callsPending = false;
3114
+ if (result.calls) installComposedCalls(index, filePath, result.calls, cached.calls);
3115
+ finishDerivation(index, filePath, rec);
3116
+ }
3117
+
2077
3118
  /** Expanded and blind invocation sites, for health and deadcode disclosure. */
2078
3119
  function rustMacroExpansionSummary(index) {
2079
3120
  const summary = { sites: 0, files: 0, blind: { count: 0, fileCount: 0, files: [], sample: [], reasons: {} } };
@@ -2110,4 +3151,8 @@ module.exports = {
2110
3151
  materializeRustMacroCalls,
2111
3152
  rustMacroExpansionSummary,
2112
3153
  rustPendingMayBear,
3154
+ rustPendingFilesBearing,
3155
+ rustPendingRecords,
3156
+ completeRustMacroCalls,
3157
+ rustUntouchedPendingFiles,
2113
3158
  };