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
95 changes: 92 additions & 3 deletions src/ast-analysis/rules/b2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,69 @@ function extractDartParamName(node: TreeSitterNode): string[] | null {
return null;
}

/** Walk backward past any unnamed (anonymous token) siblings to the nearest named one. */
function prevNamedSibling(node: TreeSitterNode): TreeSitterNode | null {
let p = node.previousSibling;
while (p && !p.isNamed) p = p.previousSibling;
return p;
}

/**
* Resolve a Dart call's callee name and argument-list node (issue #2357).
*
* tree-sitter-dart has no `call_expression` node type at all — confirmed via
* node-types.json — AND, contrary to this fix's first attempt, the grammar's
* own `postfix_expression` node (which node-types.json lists as the
* conceptual wrapper) never actually appears in a parsed tree either:
* tree-sitter elides/inlines it, so a call is a FLAT SEQUENCE OF SIBLINGS
* under whatever encloses it (`return_statement`, `expression_statement`,
* `initialized_variable_definition`, etc.) — confirmed empirically by
* dumping real parse trees for `return helper(x);`, `helper(x);`, and
* `obj.method(x);`. `helper(x)` is `[identifier "helper", selector "(x)"]`;
* `obj.method(x)` is `[identifier "obj", selector ".method", selector "(x)"]`
* — TWO sibling selectors, not one node containing both. `selector` itself
* IS a real, distinctly-dispatchable node (unlike postfix_expression), so
* `node` here is the CALL selector (the one wrapping `argument_part`) —
* `callNode: 'selector'` dispatches this function once per selector the
* generic walk visits; a non-call selector (bare `.prop` access, `[index]`)
* returns null via the argument_part check below. The callee is resolved by
* walking BACKWARD to this selector's preceding sibling: another selector
* (a `.property` access — the method-call case) or a bare `identifier` (the
* base — the bare-call case).
*
* Only resolves the OUTERMOST call in a chain (`a.b().c()` resolves `.c()`
* only) — there is no separate node for each intermediate call in Dart's
* flat sibling chain, matching this issue's own scope (a single call, not
* chained calls).
*/
function resolveDartCallParts(
node: TreeSitterNode,
): { callee: string; argsNode: TreeSitterNode } | null {
const argumentPart = node.namedChildren.find((c) => c.type === 'argument_part');
if (!argumentPart) return null;
const argsNode = argumentPart.namedChildren.find((c) => c.type === 'arguments');
if (!argsNode) return null;

const prev = prevNamedSibling(node);
if (!prev) return null;

let calleeNode: TreeSitterNode | null = null;
if (prev.type === 'selector') {
// obj.method(x): the preceding selector wraps the property identifier.
const propWrapper = prev.namedChildren[0];
calleeNode =
propWrapper?.type === 'unconditional_assignable_selector'
? (propWrapper.namedChildren[0] ?? null)
: null;
} else if (prev.type === 'identifier') {
// helper(x): the preceding identifier IS the callee.
calleeNode = prev;
}
if (calleeNode?.type !== 'identifier') return null;

return { callee: calleeNode.text, argsNode };
}

export const dataflowDart: DataflowRulesConfig = makeDataflowRules({
functionNodes: new Set(['function_signature', 'method_signature']),
// tree-sitter-dart puts a function's body in a SIBLING node, not a child
Expand All @@ -595,6 +658,14 @@ export const dataflowDart: DataflowRulesConfig = makeDataflowRules({
getParamListNode: getDartParamListNode,
extractParamName: extractDartParamName,

// local_variable_declaration's ONLY child is initialized_variable_definition,
// which has real `name`/`value` fields (confirmed via node-types.json) —
// this was left entirely unconfigured (issue #2357), so `var sum = ...;`
// never produced an assignment entry regardless of the sibling-body fix.
varDeclaratorNode: 'initialized_variable_definition',
varNameField: 'name',
varValueField: 'value',

returnNode: 'return_statement',
// Arrow/`=>`-style implicit return (issue #2356): tree-sitter-dart's
// function_body node's ONLY child is either a `block` (`{ ... }`) or one
Expand All @@ -603,9 +674,27 @@ export const dataflowDart: DataflowRulesConfig = makeDataflowRules({
// already matches Dart's own block type name.
implicitReturnBodyNode: 'function_body',

callNode: 'call_expression',
// tree-sitter-dart does not have standard named fields for calls;
// the extractor uses `selector` nodes instead. Leave defaults.
// tree-sitter-dart has no call_expression node type at all, and its
// conceptual postfix_expression wrapper never actually materializes in a
// parsed tree either — a call is a flat sequence of siblings (see
// resolveDartCallParts's doc comment, issue #2357). `selector` is the one
// node in that sequence that's reliably, distinctly dispatchable — it
// wraps `argument_part` exactly when this specific selector is a call.
callNode: 'selector',
resolveCallParts: resolveDartCallParts,
// A call-sourced var declarator's `value` field resolves to the CALLEE's
// own base expression (an `identifier`), not the call itself — the call's
// argument-list selector is a SIBLING, found by walking forward through
// this same chain of selectors (findCallSelector, dataflow-visitor.ts).
// Without this, `var x = helper(y);` never registers an assignment entry
// even though the standalone call `helper(y)` is correctly tracked above.
callChainSiblingType: 'selector',
// `arguments`'s own children wrap each argument in an `argument` node
// (confirmed via node-types.json — same wrapper name PHP's config already
// unwraps), not a bare expression directly — without this, unwrapArg never
// finds the identifier inside and argFlows stays empty regardless of the
// callNode/resolveCallParts fix above.
argumentWrapperType: 'argument',
});

// ─── Groovy ───────────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions src/ast-analysis/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export const DATAFLOW_DEFAULTS: DataflowRulesConfig = {
// Arrow/`=>`-style implicit-return body (issue #2356)
implicitReturnBodyNode: null,
blockBodyNode: 'block',

// Fieldless call structure override (issue #2357)
resolveCallParts: null,
callChainSiblingType: null,
};

export function makeDataflowRules(overrides: Partial<DataflowRulesConfig>): DataflowRulesConfig {
Expand Down
72 changes: 66 additions & 6 deletions src/ast-analysis/visitors/dataflow-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,65 @@ function recordSimpleAssignment(
scope.locals.set(varName, { type: 'call_return', callee });
}

/**
* Resolve a call node's callee name and argument-list node, using the
* language's `resolveCallParts` override when set (issue #2357 — grammars
* like tree-sitter-dart's `postfix_expression` have no field-based
* `callFunctionField`/`callArgsField` structure to read from a single
* self-contained node), falling back to the default field-based lookup
* (`resolveCalleeName` + `childForFieldName(callArgsField)`) otherwise.
*/
function resolveCallExprParts(
node: TreeSitterNode,
rules: AnyRules,
): { callee: string; argsNode: TreeSitterNode } | null {
if (rules.resolveCallParts) return rules.resolveCallParts(node);
const callee = resolveCalleeName(node, rules);
const argsNode = node.childForFieldName(rules.callArgsField);
if (!callee || !argsNode) return null;
return { callee, argsNode };
}

/**
* Find the node `resolveCallExprParts` should be given for a value expression
* that MIGHT be call-sourced. For most languages `node` (already
* field-resolved to e.g. a `call_expression`) already IS the call node —
* `isCallNode(node.type)` returns it unchanged, exactly reproducing every
* existing caller's prior `isCall(node, isCallNode) ? node : null` check
* with zero behavior change.
*
* For a grammar whose call-node dispatch target is a SIBLING of the value
* expression rather than the value expression itself (tree-sitter-dart's
* `selector`, resolved backward from the callee — see `resolveDartCallParts`,
* issue #2357), `node` is instead the callee's own base expression (e.g. the
* bare `identifier` a var's `value` field resolves to for `var x =
* helper(y);`). This walks FORWARD through `node`'s sibling chain — a call's
* argument-list selector is always last in tree-sitter-dart's flat sibling
* sequence — to find the trailing call-node sibling, the symmetric
* counterpart of `resolveDartCallParts`'s backward walk. Returns null when
* no call-node sibling follows (a non-call value expression).
*/
function findCallSelector(
node: TreeSitterNode,
rules: AnyRules,
isCallNode: (t: string) => boolean,
): TreeSitterNode | null {
if (isCallNode(node.type)) return node;
if (!rules.callChainSiblingType) return null;
let sib: TreeSitterNode | null = node.nextSibling;
let last: TreeSitterNode | null = null;
while (sib) {
if (!sib.isNamed) {
sib = sib.nextSibling;
continue;
}
if (sib.type !== rules.callChainSiblingType) break;
last = sib;
sib = sib.nextSibling;
}
return last && isCallNode(last.type) ? last : null;
}

function handleVarDeclarator(
node: TreeSitterNode,
rules: AnyRules,
Expand All @@ -222,11 +281,12 @@ function handleVarDeclarator(
if (!nameNode || !valueNode || !scope) return;

const unwrapped = unwrapAwait(valueNode, rules);
const callExpr = isCall(unwrapped, isCallNode) ? unwrapped : null;
const callExpr = findCallSelector(unwrapped, rules, isCallNode);
if (!callExpr) return;

const callee = resolveCalleeName(callExpr, rules);
if (!callee || !scope.funcName) return;
const parts = resolveCallExprParts(callExpr, rules);
if (!parts || !scope.funcName) return;
const { callee } = parts;

const isDestructured =
(rules.objectDestructType && nameNode.type === rules.objectDestructType) ||
Expand Down Expand Up @@ -315,10 +375,10 @@ function handleCallExpr(
scopeStack: ScopeEntry[],
argFlows: DataflowArgFlow[],
): void {
const callee = resolveCalleeName(node, rules);
const argsNode = node.childForFieldName(rules.callArgsField);
const parts = resolveCallExprParts(node, rules);
const scope = currentScope(scopeStack);
if (!callee || !argsNode || !scope?.funcName) return;
if (!parts || !scope?.funcName) return;
const { callee, argsNode } = parts;

let argIndex = 0;
for (const arg of argsNode.namedChildren) {
Expand Down
30 changes: 30 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,36 @@ export interface DataflowRulesConfig {
implicitReturnBodyNode: string | null;
/** The "real" statement-block wrapper type `implicitReturnBodyNode` is compared against. */
blockBodyNode: string;
/**
* Override for grammars where a call has no field-based (`callFunctionField`/
* `callArgsField`) structure to read from a single self-contained node —
* e.g. tree-sitter-dart, which has no `call_expression` node type, AND
* whose grammar-documented `postfix_expression` wrapper never actually
* appears in a parsed tree either: a call is a FLAT SEQUENCE OF SIBLINGS
* (a base expression followed by a chain of `selector` siblings, one of
* which wraps an `argument_part` when it's a call — issue #2357). When
* set, this replaces the default `resolveCalleeName` +
* `childForFieldName(callArgsField)` lookup everywhere a call's
* callee/argument-list is resolved. Returns `null` when `node` isn't
* actually a call (e.g. Dart's `selector` also represents non-call member
* access and `x++`/`x--`).
*/
resolveCallParts:
| ((node: TreeSitterNode) => { callee: string; argsNode: TreeSitterNode } | null)
| null;
/**
* The sibling node type `resolveCallParts`-style grammars chain calls
* through (Dart: `'selector'`). Lets `findCallSelector` (dataflow-visitor.ts)
* walk FORWARD from a call's base expression — resolved via the normal
* `varValueField`/`callFunctionField` lookup, landing on the base rather
* than the call itself in these grammars — to the trailing call-node
* sibling, so a call-sourced variable assignment (`var x = helper(y);`)
* is recognized the same way it already is for languages whose call node
* IS the value node directly (issue #2357). `null` for those languages:
* `findCallSelector` short-circuits via `isCallNode(node.type)` before
* ever consulting this field.
*/
callChainSiblingType: string | null;
}

/** Language rule module: exports from each language rule file. */
Expand Down
62 changes: 62 additions & 0 deletions tests/parsers/dataflow-dart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,66 @@ describe('extractDataflow — Dart', () => {
expect(multiplyReturns).toHaveLength(1);
});
});

// Issue #2357: tree-sitter-dart has no call_expression node type at all,
// and its documented postfix_expression wrapper never actually appears in
// a parsed tree either — a call is a flat sequence of siblings (a base
// expression followed by a chain of `selector` siblings, one of which
// wraps an argument_part when it's a call). callNode/resolveCallParts and
// the argument-wrapper config were entirely unset, so argFlows (and any
// call-derived dataflow) was always empty regardless of the sibling-body
// and return fixes above.
describe('calls', () => {
it('tracks a bare call argument flow (helper(x) as a return expression)', () => {
const data = parseAndExtract('int square(int x) {\n return helper(x);\n}\n');
expect(data!.argFlows).toEqual(
expect.arrayContaining([
expect.objectContaining({ callerFunc: 'square', calleeName: 'helper', argName: 'x' }),
]),
);
});

it('tracks a bare statement-level call argument flow', () => {
const data = parseAndExtract('int square(int x) {\n helper(x);\n return x;\n}\n');
expect(data!.argFlows).toEqual(
expect.arrayContaining([
expect.objectContaining({ callerFunc: 'square', calleeName: 'helper', argName: 'x' }),
]),
);
});

it('tracks a method-call argument flow, resolving the callee via the preceding property selector', () => {
const data = parseAndExtract(
'class Obj {\n int method(int x) => x;\n}\nint square(Obj obj, int x) {\n return obj.method(x);\n}\n',
);
expect(data!.argFlows).toEqual(
expect.arrayContaining([
expect.objectContaining({ callerFunc: 'square', calleeName: 'method', argName: 'x' }),
]),
);
});

it('does not misidentify a non-call postfix expression (x++) as a call', () => {
const data = parseAndExtract('int inc(int x) {\n x++;\n return x;\n}\n');
expect(data!.argFlows).toEqual([]);
});
});

describe('assignments', () => {
it('tracks a call-sourced variable assignment (var x = helper(y))', () => {
const data = parseAndExtract('int square(int y) {\n var x = helper(y);\n return x;\n}\n');
expect(data!.assignments).toEqual(
expect.arrayContaining([
expect.objectContaining({ varName: 'x', callerFunc: 'square', sourceCallName: 'helper' }),
]),
);
});

it('does not record a plain (non-call-sourced) variable declaration as an assignment', () => {
const data = parseAndExtract(
'int add(int a, int b) {\n var sum = a + b;\n return sum;\n}\n',
);
expect(data!.assignments).toEqual([]);
});
});
});
Loading