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
236 changes: 236 additions & 0 deletions packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { BaseNode, Node, Program } from 'estree';

import { walkAst } from './walk-ast';

describe('Backend AST Parsing - walkAst', () => {
test('Should visit universal and specialized visitors for ESTree-shaped nodes', () => {
const ast = buildEstreeFixture();
const state = {
visitedTypes: [] as string[],
callArgumentCounts: [] as number[],
functionNames: [] as string[],
};

walkAst(ast as Node, state, {
_(node, { state: visitorState }) {
visitorState.visitedTypes.push(node.type);
},
CallExpression(node, { state: visitorState }) {
visitorState.callArgumentCounts.push(node.arguments.length);
},
FunctionDeclaration(node, { state: visitorState }) {
if (node.id) {
visitorState.functionNames.push(node.id.name);
}
},
});

expect(state.functionNames).toEqual(['run']);
expect(state.callArgumentCounts).toEqual([1]);
expect(state.visitedTypes).toEqual(
expect.arrayContaining([
'Program',
'ImportDeclaration',
'ImportSpecifier',
'FunctionDeclaration',
'ObjectPattern',
'Property',
'Identifier',
'BlockStatement',
'ReturnStatement',
'CallExpression',
'MemberExpression',
'ObjectExpression',
'Literal',
]),
);
});

test('Should traverse arrays and nested node objects in deterministic pre-order', () => {
const ast = {
type: 'Root',
first: { type: 'First' },
children: [
{ type: 'ChildA' },
null,
{ not: 'a node' },
{ type: 'ChildB', nested: { type: 'Grandchild' } },
],
} as unknown as TestNode;
const visited: string[] = [];

walkAst(
ast,
{ visited },
{
_(node, { state }) {
state.visited.push(node.type);
},
},
);

expect(visited).toEqual(['Root', 'First', 'ChildA', 'ChildB', 'Grandchild']);
});

test('Should ignore the type property and non-node objects', () => {
const ast = {
type: 'Root',
metadata: {
type: 123,
nestedNodeThatShouldNotBeVisited: { type: 'IgnoredNestedNode' },
},
source: {
value: '@datadog/action-catalog/http/http',
},
child: { type: 'VisitedChild' },
} as unknown as TestNode;
const visited: string[] = [];

walkAst(
ast,
{ visited },
{
_(node, { state }) {
state.visited.push(node.type);
},
},
);

expect(visited).toEqual(['Root', 'VisitedChild']);
});

test('Should share one state object across all visitors', () => {
const ast = {
type: 'Root',
children: [{ type: 'ChildA' }, { type: 'ChildB' }],
} as unknown as TestNode;
const state = { count: 0 };

walkAst(ast, state, {
_(_, { state: visitorState }) {
visitorState.count += 1;
},
});

expect(state.count).toBe(3);
});
});

type TestNode = BaseNode & {
first?: TestNode;
nested?: TestNode;
child?: TestNode;
children?: Array<TestNode | null | { not: string }>;
metadata?: object;
source?: object;
};

function buildEstreeFixture(): Program {
return {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ImportDeclaration',
source: {
type: 'Literal',
value: '@datadog/action-catalog/http/http',
},
attributes: [],
specifiers: [
{
type: 'ImportSpecifier',
imported: {
type: 'Identifier',
name: 'request',
},
local: {
type: 'Identifier',
name: 'request',
},
},
],
},
{
type: 'FunctionDeclaration',
id: {
type: 'Identifier',
name: 'run',
},
params: [
{
type: 'ObjectPattern',
properties: [
{
type: 'Property',
kind: 'init',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'client',
},
value: {
type: 'Identifier',
name: 'client',
},
},
],
},
],
body: {
type: 'BlockStatement',
body: [
{
type: 'ReturnStatement',
argument: {
type: 'CallExpression',
optional: false,
callee: {
type: 'MemberExpression',
optional: false,
computed: false,
object: {
type: 'Identifier',
name: 'http',
},
property: {
type: 'Identifier',
name: 'request',
},
},
arguments: [
{
type: 'ObjectExpression',
properties: [
{
type: 'Property',
kind: 'init',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'connectionId',
},
value: {
type: 'Literal',
value: 'conn',
},
},
],
},
],
},
},
],
},
},
],
};
}
112 changes: 112 additions & 0 deletions packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { BaseNode, Node as EstreeNode } from 'estree';

/**
* Object passed to every visitor.
*
* `state` is shared for the whole walk. This helper intentionally does not
* thread child-specific state because current AST analysis only needs one
* shared collection/lookup object.
*/
export interface WalkAstContext<State> {
state: State;
}

/**
* Function called when the walker reaches a matching node.
*
* `Node` is the broad tree type passed to `walkAst`, while `CurrentNode` is the
* narrowed node type for a specialized visitor such as `CallExpression`.
*/
export type WalkAstVisitor<Node extends BaseNode, State, CurrentNode extends BaseNode = Node> = (
node: CurrentNode,
context: WalkAstContext<State>,
) => void;

/**
* Visitor map for concrete ESTree node types.
*
* The runtime walker is generic and does not special-case ESTree types. This
* mapped type only exists to make visitor callbacks typed when users write
* keys like `CallExpression` or `VariableDeclarator`.
*/
type SpecializedWalkAstVisitors<Node extends BaseNode, State> = {
[Type in EstreeNode['type']]?: WalkAstVisitor<Node, State, Extract<EstreeNode, { type: Type }>>;
};

/**
* Visitors accepted by `walkAst`.
*
* `_` is a universal visitor that runs for every node. Keys matching concrete
* ESTree node types run only for nodes with that `type`.
*/
export type WalkAstVisitors<Node extends BaseNode, State> = SpecializedWalkAstVisitors<
Node,
State
> & {
_?: WalkAstVisitor<Node, State>;
};

/**
* Walks an ESTree-shaped AST without maintaining a hardcoded visitor-key table.
*
* Any object with a string `type` property is treated as a child node. Primitive
* values, arrays entries without `type`, and metadata objects such as `loc` are
* ignored.
*/
export function walkAst<Node extends BaseNode, State>(
node: Node,
state: State,
visitors: WalkAstVisitors<Node, State>,
): void {
const context: WalkAstContext<State> = { state };

const visit = (currentNode: Node): void => {
visitors._?.(currentNode, context);
getSpecializedVisitor(currentNode, visitors)?.(currentNode, context);

for (const key of Object.keys(currentNode)) {
if (key === 'type') {
continue;
}

visitChildren((currentNode as Record<string, unknown>)[key]);
}
};

const visitChildren = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) {
if (isAstNode(item)) {
visit(item as Node);
}
}
return;
}

if (isAstNode(value)) {
visit(value as Node);
}
};

visit(node);
}

function isAstNode(value: unknown): value is BaseNode {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string'
);
}

function getSpecializedVisitor<Node extends BaseNode, State>(
node: Node,
visitors: WalkAstVisitors<Node, State>,
): WalkAstVisitor<BaseNode, State> | undefined {
return (visitors as Record<string, WalkAstVisitor<BaseNode, State> | undefined>)[node.type];
}
Loading