Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions __tests__/js-resolution-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { matchCollapsedObjectCall } from '../src/resolution/name-matcher';
import { stripCommentsForRegex } from '../src/resolution/strip-comments';
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';

// Pass-through, so a test can count how often text is stripped.
vi.mock('../src/resolution/strip-comments', async (importOriginal) => {
Expand Down Expand Up @@ -82,3 +84,88 @@ describe('JS resolution work (#2334)', () => {
expect(strips('javascript', host)).toBeLessThan(LINES / 4);
});
});

/**
* A call the extractor records by its bare name has its receiver read back
* from the source, from the ref's column on. A minified bundle is one long
* line, and joining it with the lines after it copied all of it for every
* call on it: about 1 MB per call on go-ethereum's graphiql.min.js. Counted,
* never timed.
*/
describe('a receiver read back from a minified line', () => {
/** How many characters arrays were joined into while `run` ran. */
function joinedDuring(run: () => void): number {
const join = Array.prototype.join;
let chars = 0;
Array.prototype.join = function (this: unknown[], separator?: string): string {
const text = join.call(this, separator);
chars += text.length;
return text;
};
try {
run();
} finally {
Array.prototype.join = join;
}
return chars;
}

/**
* Runs `refs` through the check for a call written on `window.App…`, which
* looks up the object the receiver it read back names.
*/
function readBack(source: string, refs: UnresolvedRef[]): { holders: string[]; joined: number } {
const lines = source.split('\n');
const holders: string[] = [];
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: (name) => {
holders.push(name);
return [];
},
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
getNodesByLowerName: () => [],
fileExists: () => false,
readFile: () => source,
getFileLines: () => lines,
getProjectRoot: () => '',
getAllFiles: () => [],
getImportMappings: () => [],
};
const joined = joinedDuring(() => {
for (const ref of refs) matchCollapsedObjectCall(ref, context);
});
return { holders, joined };
}

const call = (name: string, line: number, column: number): UnresolvedRef => ({
fromNodeId: 'bundle', referenceName: name, referenceKind: 'calls', line, column,
filePath: 'bundle.min.js', language: 'javascript',
});

it('reads every receiver on the line without copying the line once per call', () => {
const CALLS = 300;
const refs: UnresolvedRef[] = [];
let line = '!function(){';
for (let i = 0; i < CALLS; i++) {
refs.push(call(`m${i}`, 2, line.length));
line += `window.App${i}.m${i}(${i});`;
}
line += '}();';
const { holders, joined } = readBack(`/*! bundle */\n${line}\n//# sourceMappingURL=bundle.min.js.map`, refs);
// Each call's receiver was read back...
expect(holders).toEqual(refs.map((_, i) => `App${i}`));
// ...and all of them together copied less than the line once.
expect(joined).toBeLessThan(line.length);
});

it('reads on past the line when the line alone does not settle the call', () => {
// The call continues on the next lines.
expect(readBack('window.App\n .go\n (1);', [call('go', 1, 0)]).holders).toEqual(['App']);
// The line holds `foo(`, but the call is `foo[…](3)`, whose subscript runs onto the next line.
expect(readBack('window.App.foo[foo(1),\n2](3);', [call('foo', 1, 0)]).holders).toEqual(['App']);
// A column past the end of its line reads on from the next one.
expect(readBack('x\nwindow.App.go(1);', [call('go', 1, 2)]).holders).toEqual(['App']);
});
});
17 changes: 15 additions & 2 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2411,9 +2411,22 @@ function bareCallReceiver(ref: UnresolvedRef, context: ResolutionContext): { rec
if (ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null;
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
if (!lines) return null;
const text = lines.slice(ref.line - 1, ref.line + 7).join('\n').slice(Math.max(0, ref.column));
const name = ref.referenceName.replace(/\$/g, '\\$');
const at = new RegExp(`(?<![\\w$])${name}\\s*(?:<[^<>()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`).exec(text);
const call = new RegExp(`(?<![\\w$])${name}\\s*(?:<[^<>()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`);
// Search the ref's own line first: a minified bundle is one line, and
// joining it to the lines after it copied all of it for every call on it
// (about 1 MB a call on go-ethereum's graphiql.min.js). Every match starts
// with the name, so a match with no `name` before it on the line is where
// the joined lines match first too. Otherwise (no match, as for a call that
// continues on the next line, or the name earlier) the joined lines decide.
const span = lines.slice(ref.line - 1, ref.line + 7);
const from = Math.max(0, ref.column);
let text = span[0]?.slice(from) ?? '';
let at = call.exec(text);
if (!at || text.indexOf(ref.referenceName) < at.index) {
text = span.join('\n').slice(from);
at = call.exec(text);
}
if (!at) return null;
const before = text.slice(0, at.index).replace(/\s+$/, '');
if (!/\??\.$/.test(before)) return null;
Expand Down