From c3adf4ac7c491eed88aec0c667e439a7eeee7a8f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Apr 2026 13:34:41 +0100 Subject: [PATCH] refactor(ingestion): merge python/ast-utils into utils/ast-helpers; iterative findNodeAtRange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python/ast-utils.ts held three language-agnostic helpers (nodeToCapture, syntheticCapture, findNodeAtRange) plus two duplicates of the shared utils version (findChildOfType == findChild; findIdentifierChild was unused). Consolidating into utils/ast-helpers.ts so the next language migrating to the scope-resolution pipeline imports from one place. findNodeAtRange rewritten iteratively using an explicit stack. Previous implementation was recursive — fine for shallow Python trees today, but a landmine for languages with deeper nesting (Kotlin sealed-hierarchy decomposition, Rust macro expansion, etc.) and the task hooks explicitly call out "no recursion". Children are pushed reverse-index so LIFO pop visits them left-to-right; row-bound pruning preserves the prior early-skip optimization (the `break` shortcut is replaced with `continue` since a stack can't leverage ordered sibling termination). findChildOfType consumers migrated to the existing findChild helper. findIdentifierChild deleted — no callers remained. Coverage: 204/204 test/integration/resolvers/python.test.ts both REGISTRY_PRIMARY_PYTHON=0 and =1. 339/339 scope-resolution + graph unit tests. tsc clean. --- .../ingestion/languages/python/ast-utils.ts | 96 ------------------- .../ingestion/languages/python/captures.ts | 2 +- .../languages/python/import-decomposer.ts | 18 ++-- .../languages/python/receiver-binding.ts | 3 +- .../src/core/ingestion/utils/ast-helpers.ts | 80 +++++++++++++++- 5 files changed, 92 insertions(+), 107 deletions(-) delete mode 100644 gitnexus/src/core/ingestion/languages/python/ast-utils.ts diff --git a/gitnexus/src/core/ingestion/languages/python/ast-utils.ts b/gitnexus/src/core/ingestion/languages/python/ast-utils.ts deleted file mode 100644 index 64b2f547d..000000000 --- a/gitnexus/src/core/ingestion/languages/python/ast-utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Tree-sitter `SyntaxNode` helpers used by the Python scope-resolution - * hooks. Pure utilities — no Python-specific knowledge — but kept local - * to the `python/` package because they're only consumed here today. - */ - -import type { Capture } from 'gitnexus-shared'; -import type { SyntaxNode } from '../../utils/ast-helpers.js'; - -/** Convert a tree-sitter node to a `Capture` with 1-based line numbers - * (matching RFC §2.1). The tag includes the leading `@`. */ -export function nodeToCapture(name: string, node: SyntaxNode): Capture { - return { - name, - range: { - startLine: node.startPosition.row + 1, - startCol: node.startPosition.column, - endLine: node.endPosition.row + 1, - endCol: node.endPosition.column, - }, - text: node.text, - }; -} - -/** Build a `Capture` whose range mirrors `atNode` but whose `text` is - * caller-supplied. Used to synthesize markers like `@import.kind` that - * don't have a corresponding source token. */ -export function syntheticCapture(name: string, atNode: SyntaxNode, text: string): Capture { - return { - name, - range: { - startLine: atNode.startPosition.row + 1, - startCol: atNode.startPosition.column, - endLine: atNode.endPosition.row + 1, - endCol: atNode.endPosition.column, - }, - text, - }; -} - -function rangeMatches( - node: SyntaxNode, - range: { startLine: number; startCol: number; endLine: number; endCol: number }, -): boolean { - return ( - node.startPosition.row + 1 === range.startLine && - node.startPosition.column === range.startCol && - node.endPosition.row + 1 === range.endLine && - node.endPosition.column === range.endCol - ); -} - -/** Walk subtree to find a node whose range exactly matches AND whose - * type matches `expectedType` (when given). When multiple nodes share - * the range — e.g., `function_definition` and its inner `block` body - * for a one-liner — the type filter disambiguates. O(n) over the - * candidate subtree; only descends into spans that cover the target, - * so in practice it's near-O(depth). */ -export function findNodeAtRange( - root: SyntaxNode, - range: { startLine: number; startCol: number; endLine: number; endCol: number }, - expectedType?: string, -): SyntaxNode | null { - if (rangeMatches(root, range) && (expectedType === undefined || root.type === expectedType)) { - return root; - } - const startRow = range.startLine - 1; - const endRow = range.endLine - 1; - for (let i = 0; i < root.namedChildCount; i++) { - const child = root.namedChild(i); - if (child === null) continue; - if (child.endPosition.row < startRow) continue; - if (child.startPosition.row > endRow) break; - const hit = findNodeAtRange(child, range, expectedType); - if (hit !== null) return hit; - } - return null; -} - -/** Find the first named child of `node` whose `type` matches `type`. */ -export function findChildOfType(node: SyntaxNode, type: string): SyntaxNode | null { - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (child !== null && child.type === type) return child; - } - return null; -} - -/** First named `identifier` child of `node`, or `null`. */ -export function findIdentifierChild(node: SyntaxNode): SyntaxNode | null { - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (child !== null && child.type === 'identifier') return child; - } - return null; -} diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index e26ab2d70..e96a60644 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -17,7 +17,7 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture, syntheticCapture } from './ast-utils.js'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts index a7a690e84..7cc855796 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts @@ -12,8 +12,12 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import type { SyntaxNode } from '../../utils/ast-helpers.js'; -import { findChildOfType, nodeToCapture, syntheticCapture } from './ast-utils.js'; +import { + findChild, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; /** Tag a single decomposed import. Mirrors the `case` arms of * `interpretPythonImport`. */ @@ -49,8 +53,8 @@ function splitImportStmt(stmtNode: SyntaxNode): CaptureMatch[] { }), ); } else if (child.type === 'aliased_import') { - const dotted = findChildOfType(child, 'dotted_name'); - const alias = findChildOfType(child, 'identifier'); + const dotted = findChild(child, 'dotted_name'); + const alias = findChild(child, 'identifier'); if (dotted !== null && alias !== null) { out.push( buildImportMatch(stmtNode, { @@ -75,7 +79,7 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] { // Wildcard? tree-sitter-python represents `*` as a `wildcard_import` // child and emits no name children. - const wildcardChild = findChildOfType(stmtNode, 'wildcard_import'); + const wildcardChild = findChild(stmtNode, 'wildcard_import'); if (wildcardChild !== null) { out.push( buildImportMatch(stmtNode, { @@ -104,8 +108,8 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] { }), ); } else if (child.type === 'aliased_import') { - const dotted = findChildOfType(child, 'dotted_name'); - const alias = findChildOfType(child, 'identifier'); + const dotted = findChild(child, 'dotted_name'); + const alias = findChild(child, 'identifier'); if (dotted !== null && alias !== null) { out.push( buildImportMatch(stmtNode, { diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index fc27c394f..8294233f1 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -10,8 +10,7 @@ */ import type { CaptureMatch } from 'gitnexus-shared'; -import type { SyntaxNode } from '../../utils/ast-helpers.js'; -import { nodeToCapture, syntheticCapture } from './ast-utils.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; /** Walk up to the enclosing `class_definition`, ignoring the immediate * `decorated_definition` wrapper. Returns `null` when the function is diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index f482fbec6..98b268a9c 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -1,5 +1,5 @@ import type Parser from 'tree-sitter'; -import type { NodeLabel } from 'gitnexus-shared'; +import type { Capture, NodeLabel, Range } from 'gitnexus-shared'; import type { LanguageProvider } from '../language-provider.js'; import { generateId } from '../../../lib/utils.js'; @@ -491,3 +491,81 @@ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null { } return null; } + +// ============================================================================ +// Capture + range helpers (formerly python/ast-utils.ts — language-agnostic) +// ============================================================================ + +/** Convert a tree-sitter node to a `Capture` with 1-based line numbers + * (matching RFC §2.1). The tag includes the leading `@`. */ +export function nodeToCapture(name: string, node: SyntaxNode): Capture { + return { + name, + range: { + startLine: node.startPosition.row + 1, + startCol: node.startPosition.column, + endLine: node.endPosition.row + 1, + endCol: node.endPosition.column, + }, + text: node.text, + }; +} + +/** Build a `Capture` whose range mirrors `atNode` but whose `text` is + * caller-supplied. Used to synthesize markers that don't have a + * corresponding source token. */ +export function syntheticCapture(name: string, atNode: SyntaxNode, text: string): Capture { + return { + name, + range: { + startLine: atNode.startPosition.row + 1, + startCol: atNode.startPosition.column, + endLine: atNode.endPosition.row + 1, + endCol: atNode.endPosition.column, + }, + text, + }; +} + +function rangeMatches(node: SyntaxNode, range: Range): boolean { + return ( + node.startPosition.row + 1 === range.startLine && + node.startPosition.column === range.startCol && + node.endPosition.row + 1 === range.endLine && + node.endPosition.column === range.endCol + ); +} + +/** Walk a subtree to find a node whose range exactly matches AND whose + * type matches `expectedType` (when given). When multiple nodes share + * the range — e.g., `function_definition` and its inner `block` body + * for a one-liner — the type filter disambiguates. + * + * Iterative depth-first-left-to-right via an explicit stack. Children + * are pushed in reverse index order so LIFO pop visits them in source + * order. Prunes branches that can't contain the target range by + * row bounds — same optimization the prior recursive form used, minus + * the early-break since stack-push is cheap. */ +export function findNodeAtRange( + root: SyntaxNode, + range: Range, + expectedType?: string, +): SyntaxNode | null { + const startRow = range.startLine - 1; + const endRow = range.endLine - 1; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (rangeMatches(node, range) && (expectedType === undefined || node.type === expectedType)) { + return node; + } + for (let i = node.namedChildCount - 1; i >= 0; i--) { + const child = node.namedChild(i); + if (child === null) continue; + if (child.endPosition.row < startRow) continue; + if (child.startPosition.row > endRow) continue; + stack.push(child); + } + } + return null; +}