fix(kotlin): see through braced if-branches in callable alternatives (#3354)

tree-sitter-kotlin wraps a braced branch as
`control_structure_body > statements > <expr>`, so for any non-empty
block kotlinValueAlternatives saw exactly one named child (`statements`)
and pushed the wrapper itself as the branch value. operandSyntax emits
nothing for a `statements` node, so
`val run = if (c) { ::f } else { ::g }; run()` produced no flow edges,
and the "multi-statement block stays opaque" guard could never fire.

Descend one level through `statements` and require exactly one
non-comment expression there. Empty blocks (`{}` has no named children),
multi-statement blocks, and `if` without `else` still return the whole
`if` as one opaque source. The doc comment now describes that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 15:01:26 +00:00
parent 6acca5a77b
commit beeb570b82
3 changed files with 43 additions and 8 deletions

View file

@ -95,19 +95,25 @@ const KOTLIN_CALLABLE_CAPTURE_OPTIONS = {
const isKotlinComment = (node: SyntaxNode): boolean =>
node.type === 'line_comment' || node.type === 'multiline_comment';
const namedNonComments = (node: SyntaxNode): SyntaxNode[] =>
node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
);
/**
* Branches of a Kotlin value-selecting expression (#3354). `a ?: b` is a
* FIELDLESS `elvis_expression` (positional operands), so the shared
* field-based rule never saw it and only the last operand flowed. An
* `if_expression` fields its branches as `control_structure_body` wrappers:
* a wrapper holding one expression is that expression's value, anything
* else (a multi-statement block) keeps the whole `if` one opaque source.
* a bare branch (`if (c) ::f`) holds the expression directly, a braced one
* (`if (c) { ::f }`) nests it one level deeper in a `statements` node. A
* branch holding exactly one expression is that expression's value; an
* empty or multi-statement block, or an `if` without `else`, keeps the
* whole `if` one opaque source.
*/
function kotlinValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | undefined {
if (node.type === 'elvis_expression') {
const operands = node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
);
const operands = namedNonComments(node);
return operands.length === 2 ? operands : undefined;
}
if (node.type !== 'if_expression') return undefined;
@ -115,9 +121,9 @@ function kotlinValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | unde
for (const field of ['consequence', 'alternative'] as const) {
const body = node.childForFieldName(field);
if (body === null) return [node];
const values = body.namedChildren.filter(
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
);
let values = namedNonComments(body);
const [block] = values;
if (values.length === 1 && block?.type === 'statements') values = namedNonComments(block);
const [value] = values;
if (values.length !== 1 || value === undefined) return [node];
branches.push(value);

View file

@ -11,3 +11,20 @@ fun ifExpression(fast: Boolean) {
val run = if (fast) ::runThen else ::runElse
run()
}
fun runBracedThen() {}
fun runBracedElse() {}
fun braced(fast: Boolean) {
val run = if (fast) { ::runBracedThen } else { ::runBracedElse }
run()
}
fun log() {}
fun runBlockThen() {}
fun runBlockElse() {}
fun multiStatement(fast: Boolean) {
val run = if (fast) { log(); ::runBlockThen } else { ::runBlockElse }
run()
}

View file

@ -62,6 +62,18 @@ describe('Kotlin callable chosen by `?:` / `if` expression', () => {
expect.arrayContaining(['ifExpression → runThen', 'ifExpression → runElse']),
);
});
// A braced branch nests its value in a `statements` node one level below
// the `control_structure_body` wrapper.
it('`if (c) { ::f } else { ::g }` reaches both branches', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['braced → runBracedThen', 'braced → runBracedElse']),
);
});
it('a multi-statement branch keeps the whole `if` opaque', () => {
expect(callsOf(result).filter((edge) => edge.startsWith('multiStatement →'))).toEqual([]);
});
});
describe('Swift callable chosen by `??` / `?:`', () => {