perf(r): look up a wildcard import's target file in a per-pass index

expandRWildcardNames scanned every parsed file with find() for each
library() edge (450,000 edges over 3,000 files took 5.9 s). Build the
module-scope lookup once per pass, as the C, C++ and Python providers do.
This commit is contained in:
Mick Cooney 2026-10-16 17:17:01 +01:00
parent c63dcc1278
commit f78f7cbc2e
2 changed files with 38 additions and 1 deletions

View file

@ -34,6 +34,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { loadRPackageConfig } from './package-config.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { resolveRImportTarget } from '../../import-resolvers/r.js';
import {
isRGlobalNameFallbackPlausible,
@ -103,6 +104,23 @@ function emitRVirtualHeritageEdges(
}
}
/**
* `moduleScope` → its `ParsedFile`, built once per pass and keyed on the identity of the pass's
* `parsedFiles` array, which finalize passes unchanged to every `expandsWildcardTo` call. The
* lookup used to be `parsedFiles.find`, one scan of all files per `library()` edge: 450,000 edges
* over 3,000 files took 5.9 s. As in the C, C++ and Python providers, the first file wins on a
* repeated scope, as `find` did.
*/
const parsedFileByModuleScope = perFileSet(
(parsedFiles: readonly ParsedFile[]): Map<ScopeId, ParsedFile> => {
const index = new Map<ScopeId, ParsedFile>();
for (const parsed of parsedFiles) {
if (!index.has(parsed.moduleScope)) index.set(parsed.moduleScope, parsed);
}
return index;
},
);
/**
* Enumerate all top-level names exported from a target module scope's file
* (module-scope function and class bindings; functions nested in another function are not).
@ -126,7 +144,7 @@ export function expandRWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
const target = parsedFileByModuleScope(parsedFiles).get(targetModuleScope);
if (target === undefined) return [];
const { names: topLevel, dottedTails } = rFileTopLevel(target);
const seen = new Set<string>();

View file

@ -13,6 +13,7 @@ import {
rIsCallableVisibleFromCaller,
rScopeResolver,
} from '../../../../src/core/ingestion/languages/r/scope-resolver.js';
import { countedParsedFiles } from '../../../helpers/counting-file-set.js';
function parse(src: string, filePath: string): ParsedFile {
const parsed = extractParsedFile(rProvider, src, filePath);
@ -146,3 +147,21 @@ describe('expandRWildcardNames', () => {
expect(expandRWildcardNames('scope:missing' as never, [a])).toEqual([]);
});
});
describe('expandRWildcardNames: the module-scope lookup is built once per pass', () => {
it('reads each parsed file once however many wildcard edges ask', () => {
// `parsedFiles.find` per edge read the files up to the match every time. Counted element
// reads, not wall time: the figure depends on the file count and the number of passes only.
const paths = Array.from({ length: 200 }, (_, i) => `pkg/R/f${i}.R`);
const { parsedFiles, reads } = countedParsedFiles(paths);
for (let edge = 0; edge < 1_000; edge++) {
expandRWildcardNames(`module:${paths[(edge * 7) % paths.length]}`, parsedFiles);
}
expect(reads()).toBe(paths.length);
});
it('answers a scope no file owns with nothing', () => {
const { parsedFiles } = countedParsedFiles(['pkg/R/a.R']);
expect(expandRWildcardNames('module:absent' as never, parsedFiles)).toEqual([]);
});
});