Skip to content

Commit

Permalink
feat: Instrument named const and exported lambdas
Browse files Browse the repository at this point in the history
  • Loading branch information
dividedmind committed Mar 19, 2024
1 parent f215447 commit 4ec7754
Show file tree
Hide file tree
Showing 7 changed files with 2,274 additions and 178 deletions.
211 changes: 210 additions & 1 deletion src/hooks/__tests__/instrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,219 @@ describe(instrument.transform, () => {
`,
);
});

it("instruments const lambdas", () => {
const program = parse(
`
const outer = arg => arg + 42;
export const testFun = arg => {
var s42 = y => y - 42;
let s43 = y => y - 43;
const inner = x => s42(x) * 2;
return inner(arg);
};
`,
{ loc: true, source: fixAbsPath("/test/test.js"), module: true },
);

expectProgram(
instrument.transform(program),
`
const __appmapFunctionRegistry = [
{
"async": false,
"generator": false,
"id": "outer",
"params": [
{
"type": "Identifier",
"name": "arg",
},
],
"location": {
"path": "test.js",
"lineno": 2,
},
"klassOrFile": "test",
"static": true,
},
{
"async": false,
"generator": false,
"id": "inner",
"params": [
{
"type": "Identifier",
"name": "x",
},
],
"location": {
"path": "test.js",
"lineno": 7,
},
"klassOrFile": "test",
"static": true,
},
{
"async": false,
"generator": false,
"id": "testFun",
"params": [
{
"type": "Identifier",
"name": "arg",
},
],
"location": {
"path": "test.js",
"lineno": 4,
},
"klassOrFile": "test",
"static": true,
},
];
const outer = (...args) => global.AppMapRecordHook.call(
undefined,
(arg) => arg + 42,
args,
__appmapFunctionRegistry[0],
);
export const testFun = (...args) =>
global.AppMapRecordHook.call(
undefined,
(arg) => {
var s42 = y => y - 42;
let s43 = y => y - 43;
const inner = (...args) =>
global.AppMapRecordHook.call(
undefined,
(x) => s42(x) * 2,
args,
__appmapFunctionRegistry[1],
);
return inner(arg);
},
args,
__appmapFunctionRegistry[2],
);
`,
);
});

it("instruments CommonJS exported named lambdas", () => {
const program = parse(
`
exports.testFun = arg => {
const inner = x => x *2;
return inner(arg);
};
`,
{ loc: true, source: fixAbsPath("/test/test.js"), module: true },
);

expectProgram(
instrument.transform(program),
`
const __appmapFunctionRegistry = [
{
"async": false,
"generator": false,
"id": "inner",
"params": [
{
"type": "Identifier",
"name": "x",
},
],
"location": {
"path": "test.js",
"lineno": 3,
},
"klassOrFile": "test",
"static": true,
},
{
"async": false,
"generator": false,
"id": "testFun",
"params": [
{
"type": "Identifier",
"name": "arg",
},
],
"location": {
"path": "test.js",
"lineno": 2,
},
"klassOrFile": "test",
"static": true,
},
];
exports.testFun = (...args) =>
global.AppMapRecordHook.call(
undefined,
(arg) => {
const inner = (...args) =>
global.AppMapRecordHook.call(
undefined,
(x) => x * 2,
args,
__appmapFunctionRegistry[0],
);
return inner(arg);
},
args,
__appmapFunctionRegistry[1],
);
`,
);
});

it("instruments const lambdas which are later CommonJS exported", () => {
const program = parse(
`
const testFun = x => x * 2
exports.testFun = testFun;
`,
{ loc: true, source: fixAbsPath("/test/test.js"), module: true },
);

expectProgram(
instrument.transform(program),
`
const __appmapFunctionRegistry = [{
"async": false,
"generator": false,
"id": "testFun",
"params": [{
"type": "Identifier",
"name": "x"
}],
"location": {
"path": "test.js",
"lineno": 2
},
"klassOrFile": "test",
"static": true
}];
const testFun = (...args) => global.AppMapRecordHook.call(undefined, x => x * 2,
args, __appmapFunctionRegistry[0]);
exports.testFun = testFun;
`,
);
});
});

function expectProgram(actual: ESTree.Program, expected: string) {
expect(generate(actual)).toEqual(generate(parse(expected)));
expect(generate(actual)).toEqual(generate(parse(expected, { module: true })));
}

jest.mock("../../config");
109 changes: 92 additions & 17 deletions src/hooks/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../generate";
import { FunctionInfo, SourceLocation, createFunctionInfo, createMethodInfo } from "../registry";
import findLast from "../util/findLast";
import { isId } from "../util/isId";

const debug = debuglog("appmap:instrument");

Expand All @@ -47,7 +48,7 @@ export function transform(program: ESTree.Program, sourceMap?: SourceMapConsumer
const location = locate(fun);
if (!location) return; // don't instrument generated code

fun.body = wrapWithRecord(fun, createFunctionInfo(fun, location), false);
fun.body = wrapFunction(fun, createFunctionInfo(fun, location), false);
},

MethodDefinition(method: ESTree.MethodDefinition, _: unknown, ancestors: ESTree.Node[]) {
Expand All @@ -71,12 +72,49 @@ export function transform(program: ESTree.Program, sourceMap?: SourceMapConsumer
if (!location) return; // don't instrument generated code

debug(`Instrumenting ${qname}`);
method.value.body = wrapWithRecord(
method.value.body = wrapFunction(
{ ...method.value },
createMethodInfo(method, klass, location),
method.kind === "constructor",
);
},

// instrument arrow functions
ArrowFunctionExpression(fun: ESTree.ArrowFunctionExpression, _, ancestors: ESTree.Node[]) {
const location = locate(fun);
if (!location) return; // don't instrument generated code

const [declaration, declarator] = ancestors.slice(-3);
switch (declarator.type) {
// instrument consts
case "VariableDeclarator": {
if (!(declaration.type === "VariableDeclaration" && declaration.kind === "const")) return;
const { id } = declarator;
if (!isId(id)) return;
if (pkg?.exclude?.includes(id.name)) return;
assert(declarator.init === fun);

debug(`Instrumenting ${id.name}`);
declarator.init = wrapLambda(
fun,
createFunctionInfo({ ...fun, id, generator: false }, location),
);
break;
}
// instrument CommonJS exports
case "AssignmentExpression": {
const id = exportName(declarator.left);
if (!id || pkg?.exclude?.includes(id.name)) return;

debug(`Instrumenting ${id.name}`);
declarator.right = wrapLambda(
fun,
createFunctionInfo({ ...fun, id, generator: false }, location),
);
break;
}
}
},
});

if (transformedFunctionInfos.length === 0) return program;
Expand All @@ -101,6 +139,25 @@ export function transform(program: ESTree.Program, sourceMap?: SourceMapConsumer
return program;
}

function wrapLambda(
lambda: ESTree.ArrowFunctionExpression,
functionInfo: FunctionInfo,
): ESTree.ArrowFunctionExpression {
const args = identifier("args");
return {
type: "ArrowFunctionExpression",
async: false,
expression: false,
params: [
{
type: "RestElement",
argument: args,
},
],
body: wrapCallable(lambda, functionInfo, identifier("undefined"), args),
};
}

function makeLocator(
sourceMap?: SourceMapConsumer,
): (node: ESTree.Node) => SourceLocation | undefined {
Expand Down Expand Up @@ -131,15 +188,33 @@ function objectLiteralExpression(obj: object) {
return parsed.body[0].expression;
}

function wrapWithRecord(
function wrapCallable(
fd: ESTree.FunctionDeclaration | ESTree.FunctionExpression | ESTree.ArrowFunctionExpression,
functionInfo: FunctionInfo,
thisArg: ESTree.Expression,
argsArg: ESTree.Expression,
): ESTree.CallExpression {
const functionArgument: ESTree.Expression =
fd.type === "ArrowFunctionExpression"
? fd
: fd.generator
? { ...fd, type: "FunctionExpression" }
: toArrowFunction(fd);
return call_(
memberId("global", "AppMapRecordHook", "call"),
thisArg,
functionArgument,
argsArg,
member(__appmapFunctionRegistryIdentifier, literal(addTransformedFunctionInfo(functionInfo))),
);
}

function wrapFunction(
fd: ESTree.FunctionDeclaration | ESTree.FunctionExpression,
functionInfo: FunctionInfo,
thisIsUndefined: boolean,
) {
): ESTree.BlockStatement {
const statement = fd.generator ? yieldStar : ret;
const functionArgument: ESTree.Expression = fd.generator
? { ...fd, type: "FunctionExpression" }
: toArrowFunction(fd);

const wrapped: ESTree.BlockStatement = {
type: "BlockStatement",
Expand All @@ -149,16 +224,7 @@ function wrapWithRecord(
// If it's a generator function then pass it as a generator function and yield* the result:
// yield* global.AppMapRecordHook(this|undefined, function* f() {...}, arguments, __appmapFunctionRegistry[i])
statement(
call_(
memberId("global", "AppMapRecordHook", "call"),
thisIsUndefined ? identifier("undefined") : this_,
functionArgument,
args_,
member(
__appmapFunctionRegistryIdentifier,
literal(addTransformedFunctionInfo(functionInfo)),
),
),
wrapCallable(fd, functionInfo, thisIsUndefined ? identifier("undefined") : this_, args_),
),
],
};
Expand Down Expand Up @@ -191,3 +257,12 @@ function methodHasName(
): method is ESTree.MethodDefinition & { key: { name: string } } {
return method.key !== null && "name" in method.key;
}

// returns the export name if expr is a CommonJS export member
function exportName(expr: ESTree.Expression): ESTree.Identifier | undefined {
if (expr.type === "MemberExpression" && isId(expr.object, "exports")) {
const { property } = expr;
if (isId(property)) return property;
}
return undefined;
}
2 changes: 1 addition & 1 deletion src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface FunctionInfo {
}

export function createFunctionInfo(
fun: ESTree.FunctionDeclaration & {
fun: Omit<ESTree.FunctionDeclaration, "type" | "body"> & {
id: ESTree.Identifier;
},
location: SourceLocation,
Expand Down
Loading

0 comments on commit 4ec7754

Please sign in to comment.