"""
Enhanced AST/ASG analysis tools for the MCP server.
This module provides improved implementations of the AST/ASG analysis tools
with better scope handling, more complete edge detection, and performance
optimizations for handling large codebases.
"""
import os
from collections import defaultdict
from typing import Any, Dict, List, Optional, TypedDict
from tree_sitter import Parser, Tree
from .tools import LANGUAGE_MAP, detect_language, init_parsers, languages, node_to_dict
# Create a parser instance
parser = Parser()
# Types of control flow nodes in Python
PYTHON_CONTROL_FLOW_NODES = {
"if_statement",
"for_statement",
"while_statement",
"try_statement",
"with_statement",
"match_statement",
}
# Types of nodes that create new scopes in Python
PYTHON_SCOPE_NODES = {
"function_definition",
"class_definition",
"for_statement",
"while_statement",
"with_statement",
}
# Types of control flow nodes in JavaScript/TypeScript
JS_CONTROL_FLOW_NODES = {
"if_statement",
"for_statement",
"for_in_statement",
"while_statement",
"do_statement",
"switch_statement",
"try_statement",
"catch_clause",
"finally_clause",
}
# Types of nodes that create new scopes in JavaScript/TypeScript
JS_SCOPE_NODES = {
"function_declaration",
"function_expression",
"arrow_function",
"method_definition",
"class_declaration",
"class_expression",
# Block scoping for let/const (simplified: treat all blocks as scopes for safety)
"statement_block",
}
class AstNode(TypedDict):
id: str
type: str
text: str
start_byte: int
end_byte: int
start_line: int
start_col: int
end_line: int
end_col: int
class ScopeManager:
"""Manages scope hierarchy for semantic analysis."""
def __init__(self) -> None:
self.scopes: dict[str, str] = {} # Maps scope_id to parent_scope_id
self.variables: defaultdict[str, dict[str, str]] = defaultdict(
dict
) # Maps scope_id -> {var_name: var_id}
self.functions: dict[str, str] = {} # Maps func_name to func_id
self.classes: dict[str, str] = {} # Maps class_name to class_id
self.imports: dict[str, str] = {} # Maps import_name to import_id
self.global_scope = "global"
self.control_flow: list[str] = [] # Stack of control flow nodes
def enter_scope(self, scope_id: str, parent_scope_id: Optional[str] = None) -> str:
"""
Enter a new scope.
Args:
scope_id: ID of the new scope
parent_scope_id: ID of the parent scope (None for global scope)
Returns:
The scope_id for chaining
"""
if parent_scope_id is None:
parent_scope_id = self.global_scope
self.scopes[scope_id] = parent_scope_id
return scope_id
def get_parent_scope(self, scope_id: str) -> Optional[str]:
"""Get the parent scope of the given scope."""
return self.scopes.get(scope_id)
def add_variable(self, var_name: str, var_id: str, scope_id: str) -> None:
"""Add a variable to the current scope."""
self.variables[scope_id][var_name] = var_id
def add_function(self, func_name: str, func_id: str) -> None:
"""Add a function definition."""
self.functions[func_name] = func_id
def add_class(self, class_name: str, class_id: str) -> None:
"""Add a class definition."""
self.classes[class_name] = class_id
def add_import(self, import_name: str, import_id: str) -> None:
"""Add an import definition."""
self.imports[import_name] = import_id
def find_variable(self, var_name: str, scope_id: str) -> Optional[str]:
"""
Find a variable in the scope hierarchy.
Looks in current scope first, then parent scopes.
Args:
var_name: Name of the variable to find
scope_id: ID of the scope to start the search from
Returns:
The variable ID if found, None otherwise
"""
# Use an optional type for current_scope to handle possible None returns
current_scope: Optional[str] = scope_id
while current_scope is not None:
if var_name in self.variables[current_scope]:
return self.variables[current_scope][var_name]
# Move up to parent scope
current_scope = self.get_parent_scope(current_scope)
return None
def find_function(self, func_name: str) -> Optional[str]:
"""Find a function by name."""
return self.functions.get(func_name)
def find_class(self, class_name: str) -> Optional[str]:
"""Find a class by name."""
return self.classes.get(class_name)
def find_import(self, import_name: str) -> Optional[str]:
"""Find an import by name."""
return self.imports.get(import_name)
def enter_control_flow(self, node_id: str) -> None:
"""Push a control flow node onto the stack."""
self.control_flow.append(node_id)
def exit_control_flow(self) -> Optional[str]:
"""Pop a control flow node from the stack."""
if self.control_flow:
return self.control_flow.pop()
return None
def get_current_control_flow(self) -> Optional[str]:
"""Get the current control flow node."""
if self.control_flow:
return self.control_flow[-1]
return None
def parse_code_to_ast_incremental(
code: Optional[str] = None,
language: Optional[str] = None,
filename: Optional[str] = None,
previous_tree: Optional[Tree] = None,
old_code: Optional[str] = None,
include_children: bool = True,
include_tree: bool = False,
) -> Dict[str, Any]:
"""
Parse code into an AST incrementally using Tree-sitter.
This is an optimized version that can use a previous tree to
only parse the changed parts of the code, which is much faster
for large files with small changes.
Args:
code: Source code to parse
language: Programming language identifier (optional)
filename: Source file name (optional, used for language detection)
previous_tree: Previously parsed tree (optional, for incremental parsing)
old_code: Previous version of the code (required if previous_tree is provided)
include_children: Whether to include child nodes in the result
Returns:
Dictionary representation of the AST, with additional metadata
for incremental parsing
"""
# Initialize parsers if not done already
if not languages and not init_parsers():
return {
"error": "Tree-sitter language parsers not available. Run build_parsers.py first."
}
# Read from file if code is not provided
if code is None:
if filename and os.path.exists(filename):
try:
with open(filename, "r", encoding="utf-8") as f:
code = f.read()
except Exception as e:
return {"error": f"Error reading file {filename}: {e}"}
else:
return {"error": "Code content or valid filename must be provided"}
# Detect language if not provided
if not language:
language = detect_language(code, filename)
# Normalize language identifier
language = LANGUAGE_MAP.get(language.lower(), language.lower())
# Check if language is supported
if language not in languages:
return {"error": f"Unsupported language: {language}"}
try:
# Set the parser language
parser.language = languages[language]
# Parse the code, potentially incrementally
# Ensure code is string (handled by check above)
if code is None:
code = "" # Should not happen due to check above, satisfies type checker
source_bytes = bytes(code, "utf-8")
if previous_tree and old_code:
tree = parser.parse(source_bytes, previous_tree)
# Calculate which nodes changed
changed_ranges = []
for edit in tree.changed_ranges(previous_tree):
changed_ranges.append(
{
"start_byte": edit.start_byte,
"end_byte": edit.end_byte,
"start_point": {
"row": edit.start_point[0],
"column": edit.start_point[1],
},
"end_point": {
"row": edit.end_point[0],
"column": edit.end_point[1],
},
}
)
else:
tree = parser.parse(source_bytes)
changed_ranges = None # No previous tree to compare with
# Convert to dictionary
root_node = tree.root_node
ast = node_to_dict(root_node, source_bytes, include_children)
# NOTE: tree_object removed from result to prevent C-based Tree reference leaks
# The tree object is only needed internally for incremental parsing
result = {
"language": language,
"ast": ast,
}
if include_tree:
result["tree_object"] = tree
if changed_ranges:
result["changed_ranges"] = changed_ranges
return result
except Exception as e:
return {"error": f"Error parsing code: {e}"}
def create_enhanced_asg_from_ast(ast_data: Dict) -> Dict:
"""
Create an enhanced Abstract Semantic Graph (ASG) from an AST.
This version provides more complete edge detection, including improved
scope handling and control flow edges.
Args:
ast_data: AST data from parse_code_to_ast
Returns:
Dictionary representation of the enhanced ASG
"""
if "error" in ast_data:
return ast_data
ast = ast_data["ast"]
language = ast_data["language"]
# Extract nodes and edges from the AST
nodes: List[AstNode] = []
edges = []
node_ids = {} # Map of {node_id: node_index} for quick lookups
def extract_nodes(node: Dict[str, Any], parent_id: Optional[str] = None) -> str:
node_id = f"{node['type']}_{node['start_byte']}_{node['end_byte']}"
# Create a node object with metadata
node_index = len(nodes)
node_obj: AstNode = {
"id": node_id,
"type": node["type"],
"text": node["text"],
"start_byte": node["start_byte"],
"end_byte": node["end_byte"],
"start_line": node["start_point"]["row"],
"start_col": node["start_point"]["column"],
"end_line": node["end_point"]["row"],
"end_col": node["end_point"]["column"],
}
nodes.append(node_obj)
node_ids[node_id] = node_index
# Add edge to parent if exists
if parent_id:
edges.append({"source": parent_id, "target": node_id, "type": "contains"})
# Process children
if "children" in node:
for child in node["children"]:
extract_nodes(child, node_id)
return node_id
# Start extraction from the root
root_id = extract_nodes(ast)
# Add semantic edges based on language-specific rules
if language == "python":
add_enhanced_python_semantic_edges(ast, edges)
elif language in ["javascript", "typescript"]:
add_enhanced_js_ts_semantic_edges(ast, edges)
# Add additional metadata to the ASG
return {
"language": language,
"nodes": nodes,
"edges": edges,
"root": root_id,
"node_lookup": node_ids, # Helps with quick node lookup by ID
}
def add_enhanced_python_semantic_edges(ast: Dict, edges: List[Dict]) -> None:
"""
Add enhanced Python-specific semantic edges to the ASG.
This version provides more complete edge detection, including:
- Proper scope hierarchy and variable resolution
- Control flow edges between blocks
- Data flow edges showing variable dependencies
Args:
ast: The Python AST
edges: List to store the detected edges
"""
scope_manager = ScopeManager()
current_scope = scope_manager.global_scope
# First pass: find all definitions (functions, classes, variables)
def find_enhanced_definitions(
node: Dict[str, Any], scope: Optional[str] = None
) -> None:
nonlocal current_scope
old_scope = current_scope
node_id = f"{node['type']}_{node['start_byte']}_{node['end_byte']}"
# Check for scope-creating nodes
if node["type"] in PYTHON_SCOPE_NODES:
# Create new scope for this node
current_scope = scope_manager.enter_scope(node_id, current_scope)
# Check for definitions
if node["type"] == "function_definition":
# Get function name
for child in node.get("children", []):
if child["type"] == "identifier":
func_name = child["text"]
func_id = node_id
scope_manager.add_function(func_name, func_id)
# Add parameters to function scope
for param_child in node.get("children", []):
if param_child["type"] == "parameters":
for param in param_child.get("children", []):
if param["type"] == "identifier":
param_name = param["text"]
param_id = f"{param['type']}_{param['start_byte']}_{param['end_byte']}"
scope_manager.add_variable(
param_name, param_id, current_scope
)
break
elif node["type"] == "class_definition":
# Get class name
for child in node.get("children", []):
if child["type"] == "identifier":
class_name = child["text"]
class_id = node_id
scope_manager.add_class(class_name, class_id)
break
elif node["type"] == "assignment":
# Handle variable assignments
# The left side is the target (variable being defined)
targets = []
# Split children into targets and values
for i, child in enumerate(node.get("children", [])):
if child["type"] == "=" and i > 0:
# Everything before '=' is a target
targets = node["children"][:i]
# Everything after '=' is a value (processed by generic recursion)
# values = node["children"][i+1:] # Removed as `values` list is unused
break
# Process targets (variables being assigned)
for target in targets:
if target["type"] == "identifier":
var_name = target["text"]
var_id = (
f"{target['type']}_{target['start_byte']}_{target['end_byte']}"
)
scope_manager.add_variable(var_name, var_id, current_scope)
# Handle tuple unpacking
elif target["type"] == "tuple" or target["type"] == "list":
for element in target.get("children", []):
if element["type"] == "identifier":
var_name = element["text"]
var_id = f"{element['type']}_{element['start_byte']}_{element['end_byte']}"
scope_manager.add_variable(var_name, var_id, current_scope)
elif (
node["type"] == "import_statement"
or node["type"] == "import_from_statement"
):
# Track imported modules and functions
for child in node.get("children", []):
if child["type"] == "dotted_name" or child["type"] == "identifier":
import_name = child["text"]
import_id = (
f"{child['type']}_{child['start_byte']}_{child['end_byte']}"
)
scope_manager.add_import(import_name, import_id)
# Check for control flow nodes
if node["type"] in PYTHON_CONTROL_FLOW_NODES:
scope_manager.enter_control_flow(node_id)
# Add control flow edges between blocks
body_node = None
for child in node.get("children", []):
if child["type"] == "block":
body_node = child
break
if body_node:
# Add control flow edge from this node to its body
edges.append(
{
"source": node_id,
"target": f"{body_node['type']}_{body_node['start_byte']}_{body_node['end_byte']}",
"type": "control_flow",
}
)
# Process all children recursively
for child in node.get("children", []):
find_enhanced_definitions(child, current_scope)
# Exit any control flow blocks we entered
if node["type"] in PYTHON_CONTROL_FLOW_NODES:
scope_manager.exit_control_flow()
# Restore previous scope if we created a new one
if node["type"] in PYTHON_SCOPE_NODES:
current_scope = old_scope
# Second pass: find all references and connect the edges
def find_enhanced_references(
node: Dict[str, Any], scope: Optional[str] = None
) -> None:
nonlocal current_scope
old_scope = current_scope
node_id = f"{node['type']}_{node['start_byte']}_{node['end_byte']}"
# Update scope if needed
if node["type"] in PYTHON_SCOPE_NODES:
current_scope = node_id
# Look for references to functions, variables, etc.
if node["type"] == "call":
# Find the function name (first child is usually the function being called)
func_node = None
for child in node.get("children", []):
if child["type"] == "identifier":
func_node = child
break
if func_node:
func_name = func_node["text"]
caller_id = f"{func_node['type']}_{func_node['start_byte']}_{func_node['end_byte']}"
# Look for the function definition
func_id = scope_manager.find_function(func_name)
if func_id:
edges.append(
{"source": caller_id, "target": func_id, "type": "calls"}
)
# Check if it's an imported function
import_id = scope_manager.find_import(func_name)
if import_id:
edges.append(
{
"source": caller_id,
"target": import_id,
"type": "calls_import",
}
)
elif node["type"] == "identifier":
# Check if this is a variable reference (not a definition)
parent_type = None
if node.get("parent"):
parent_type = node["parent"]["type"]
# Skip if this is a definition (handled in the first pass)
if parent_type not in [
"function_definition",
"class_definition",
"parameter",
]:
var_name = node["text"]
var_id = node_id
# Look for the variable definition
ref_id = scope_manager.find_variable(var_name, current_scope)
if ref_id and ref_id != var_id: # Don't link to self
edges.append(
{"source": var_id, "target": ref_id, "type": "references"}
)
# Process all children recursively
for child in node.get("children", []):
find_enhanced_references(child, current_scope)
# Restore previous scope if we created a new one
if node["type"] in PYTHON_SCOPE_NODES:
current_scope = old_scope
# Run both passes
find_enhanced_definitions(ast)
find_enhanced_references(ast)
def add_enhanced_js_ts_semantic_edges(ast: Dict, edges: List[Dict]) -> None:
"""
Add enhanced JavaScript/TypeScript-specific semantic edges to the ASG.
Similar to the Python version, but adapted for JS/TS syntax.
Args:
ast: The JS/TS AST
edges: List to store the detected edges
"""
scope_manager = ScopeManager()
current_scope = scope_manager.global_scope
# First pass: find all definitions
def find_enhanced_definitions(
node: Dict[str, Any], scope: Optional[str] = None
) -> None:
nonlocal current_scope
old_scope = current_scope
node_id = f"{node['type']}_{node['start_byte']}_{node['end_byte']}"
# Check for scope-creating nodes
if node["type"] in JS_SCOPE_NODES:
current_scope = scope_manager.enter_scope(node_id, current_scope)
# Process definitions
if node["type"] in ["function_declaration", "generator_function_declaration"]:
for child in node.get("children", []):
if child["type"] == "identifier":
func_name = child["text"]
func_id = node_id
scope_manager.add_function(func_name, func_id)
# Add parameters
for sib in node.get("children", []):
if sib["type"] == "formal_parameters":
for param in sib.get("children", []):
if param["type"] == "identifier":
param_name = param["text"]
param_id = f"{param['type']}_{param['start_byte']}_{param['end_byte']}"
scope_manager.add_variable(
param_name, param_id, current_scope
)
break
elif node["type"] == "class_declaration":
for child in node.get("children", []):
if child["type"] == "identifier":
class_name = child["text"]
class_id = node_id
scope_manager.add_class(class_name, class_id)
break
elif node["type"] == "variable_declarator":
# var/let/const name = value
for child in node.get("children", []):
if child["type"] == "identifier":
var_name = child["text"]
var_id = (
f"{child['type']}_{child['start_byte']}_{child['end_byte']}"
)
scope_manager.add_variable(var_name, var_id, current_scope)
break
elif node["type"] == "import_specifier":
# import { name } from ...
for child in node.get("children", []):
if child["type"] == "identifier":
import_name = child["text"]
import_id = (
f"{child['type']}_{child['start_byte']}_{child['end_byte']}"
)
scope_manager.add_import(import_name, import_id)
# Assuming last identifier is the local name if aliased
elif node["type"] == "import_clause":
# import name from ... (default import)
for child in node.get("children", []):
if child["type"] == "identifier":
import_name = child["text"]
import_id = (
f"{child['type']}_{child['start_byte']}_{child['end_byte']}"
)
scope_manager.add_import(import_name, import_id)
# Control flow
if node["type"] in JS_CONTROL_FLOW_NODES:
scope_manager.enter_control_flow(node_id)
# Simple control flow edge to body/consequent
for child in node.get("children", []):
if child["type"] in ["statement_block", "block"]:
body_id = (
f"{child['type']}_{child['start_byte']}_{child['end_byte']}"
)
edges.append(
{"source": node_id, "target": body_id, "type": "control_flow"}
)
# Recurse
for child in node.get("children", []):
find_enhanced_definitions(child, current_scope)
# Exit control flow
if node["type"] in JS_CONTROL_FLOW_NODES:
scope_manager.exit_control_flow()
# Restore scope
if node["type"] in JS_SCOPE_NODES:
current_scope = old_scope
# Second pass: references
def find_enhanced_references(
node: Dict[str, Any], scope: Optional[str] = None
) -> None:
nonlocal current_scope
old_scope = current_scope
node_id = f"{node['type']}_{node['start_byte']}_{node['end_byte']}"
if node["type"] in JS_SCOPE_NODES:
current_scope = node_id
if node["type"] == "call_expression":
# myFunc()
# First child is usually the identifier or member expression
callee = None
if node.get("children"):
callee = node["children"][0]
if callee and callee["type"] == "identifier":
func_name = callee["text"]
caller_id = (
f"{callee['type']}_{callee['start_byte']}_{callee['end_byte']}"
)
func_id = scope_manager.find_function(func_name)
if func_id:
edges.append(
{"source": caller_id, "target": func_id, "type": "calls"}
)
import_id = scope_manager.find_import(func_name)
if import_id:
edges.append(
{
"source": caller_id,
"target": import_id,
"type": "calls_import",
}
)
elif node["type"] == "identifier":
# Variable reference?
# Filter out declaration contexts
# This check is tricky without parent pointers in the dict, but we can infer
# or rely on the fact that declarations were handled in pass 1.
# A robust implementation checks definition vs usage contexts.
var_name = node["text"]
var_id = node_id
ref_id = scope_manager.find_variable(var_name, current_scope)
if ref_id and ref_id != var_id:
edges.append({"source": var_id, "target": ref_id, "type": "references"})
# Recurse
for child in node.get("children", []):
find_enhanced_references(child, current_scope)
if node["type"] in JS_SCOPE_NODES:
current_scope = old_scope
find_enhanced_definitions(ast)
find_enhanced_references(ast)
def generate_ast_diff(
ast_old: Dict, ast_new: Dict, source_old: str, source_new: str
) -> Dict:
"""
Generate a diff between two ASTs, showing only the changed nodes.
This is useful for incremental updates, where only the changed parts
of the AST need to be processed, saving time and memory for large files.
Args:
ast_old: Old AST data
ast_new: New AST data
source_old: Old source code
source_new: New source code
Returns:
Dictionary with the changed nodes and metadata
"""
# If we don't have tree objects, parse both files (can't use incremental parsing)
if "tree_object" not in ast_old or "tree_object" not in ast_new:
return {"error": "Both ASTs must have tree_object property for diffing"}
old_tree = ast_old["tree_object"]
new_tree = ast_new["tree_object"]
# Get changed ranges from Tree-sitter
# old_source_bytes and new_source_bytes are not needed here
# as the tree objects are already created from byte strings.
# Get the changed ranges
changed_ranges = []
for edit in new_tree.changed_ranges(old_tree):
changed_ranges.append(
{
"start_byte": edit.start_byte,
"end_byte": edit.end_byte,
"start_point": {
"row": edit.start_point[0],
"column": edit.start_point[1],
},
"end_point": {"row": edit.end_point[0], "column": edit.end_point[1]},
}
)
# Find nodes in the new AST that are in the changed ranges
changed_nodes = []
def find_nodes_in_range(node: Dict[str, Any], ranges: List[Dict[str, Any]]) -> bool:
# Check if this node is in any of the changed ranges
node_start = node["start_byte"]
node_end = node["end_byte"]
for r in ranges:
# If there's any overlap between the node and the range
if not (node_end <= r["start_byte"] or node_start >= r["end_byte"]):
changed_nodes.append(node)
return True
# If node is not changed, check its children
for child in node.get("children", []):
if find_nodes_in_range(child, ranges):
return True
return False
# Start from the root
find_nodes_in_range(ast_new["ast"], changed_ranges)
return {
"language": ast_new["language"],
"changed_ranges": changed_ranges,
"changed_nodes": changed_nodes,
"old_ast": ast_old["ast"],
"new_ast": ast_new["ast"],
}
def get_node_by_position(ast: Dict, line: int, column: int) -> Optional[Dict]:
"""
Find the most specific node at a given line and column position.
This is useful for pinpointing a specific location in the code,
for example to find what function or variable is at the cursor position.
Args:
ast: The AST data
line: Line number (0-based)
column: Column number (0-based)
Returns:
The node at the given position, or None if not found
"""
def find_node(node: Dict[str, Any]) -> Optional[Dict[str, Any]]:
# Check if the position is within this node's range
if node["start_point"]["row"] <= line <= node["end_point"]["row"]:
# If on start or end line, check column as well
if (
node["start_point"]["row"] == line
and column < node["start_point"]["column"]
):
return None
if (
node["end_point"]["row"] == line
and column > node["end_point"]["column"]
):
return None
# Position is within this node, check children for more specific match
best_match = node
for child in node.get("children", []):
child_match = find_node(child)
if child_match is not None:
# Child contains the position, its more specific than current node
best_match = child_match
return best_match
return None
return find_node(ast["ast"])
def register_enhanced_tools(mcp_server: Any) -> None:
"""Register all enhanced tools with the MCP server."""
@mcp_server.tool(name="parse_to_ast_incremental")
def parse_to_ast_incremental(
code: Optional[str] = None,
old_code: Optional[str] = None,
language: Optional[str] = None,
filename: Optional[str] = None,
) -> Dict[str, Any]:
"""Step 1 (Enhanced): Incremental parsing. Use this instead of `parse_to_ast` for large files or edits."""
previous_tree = None
if old_code:
old_result = parse_code_to_ast_incremental(
old_code, language=language, filename=filename
)
if "error" not in old_result and "tree_object" in old_result:
previous_tree = old_result["tree_object"]
return parse_code_to_ast_incremental(
code, language, filename, previous_tree, old_code
)
@mcp_server.tool()
def generate_enhanced_asg(
code: Optional[str] = None,
language: Optional[str] = None,
filename: Optional[str] = None,
) -> Dict[str, Any]:
"""Step 3 (Enhanced): Deep semantic analysis (Scope, Data Flow). Use for refactoring or complex queries."""
ast_data = parse_code_to_ast_incremental(
code, old_code=None, language=language, filename=filename
)
return create_enhanced_asg_from_ast(ast_data)
@mcp_server.tool()
def diff_ast(
old_code: str,
new_code: str,
language: Optional[str] = None,
filename: Optional[str] = None,
) -> Dict[str, Any]:
"""Compare two code versions semantically. Returns AST differences (nodes added/removed/changed)."""
ast_old = parse_code_to_ast_incremental(
old_code, language=language, filename=filename, include_tree=True
)
ast_new = parse_code_to_ast_incremental(
new_code, language=language, filename=filename, include_tree=True
)
if "error" in ast_old:
return ast_old
if "error" in ast_new:
return ast_new
return generate_ast_diff(ast_old, ast_new, old_code, new_code)
@mcp_server.tool()
def find_node_at_position(
code: Optional[str] = None,
line: int = 0,
column: int = 0,
language: Optional[str] = None,
filename: Optional[str] = None,
) -> Dict[str, Any]:
"""Interactive: Get AST node at a specific cursor line/column. Use for cursor-based context."""
ast_data = parse_code_to_ast_incremental(
code, language=language, filename=filename
)
if "error" in ast_data:
return ast_data
node = get_node_by_position(ast_data, line, column)
if node:
return {"node": node, "language": ast_data["language"]}
else:
return {"error": f"No node found at position {line}:{column}"}
return None