mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
refactor(ingestion): merge python/ast-utils into utils/ast-helpers; iterative findNodeAtRange
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.
This commit is contained in:
parent
4ce1c419e3
commit
c3adf4ac7c
5 changed files with 92 additions and 107 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue