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
70 changes: 64 additions & 6 deletions scripts/base_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,36 @@ class BaseParser:
def __init__(self, language: Language):
self.language = language
self.parser = Parser(language)
# Keep a reference to the most recent Tree so it is not garbage-
# collected while callers still hold Node references into it.
# Tree-sitter nodes point into memory owned by the Tree; if Python
# frees the Tree the node pointers dangle and accessing
# ``node.children`` / ``node.start_point`` raises SIGSEGV (issue #116).
self._last_tree = None

def parse(self, content: bytes) -> Node:
"""Parse source content and return root node."""
return self.parser.parse(content).root_node
"""Parse source content and return root node.

Keeps a reference to the underlying Tree on ``self._last_tree`` so
the returned root node (and any descendants obtained via
``node.children`` / ``child_by_field_name``) remains valid until
the next call to :meth:`parse`. Callers that need to hold nodes
across multiple parses must keep their own reference to the Tree
(returned via :meth:`parse_tree`).
"""
tree = self.parser.parse(content)
self._last_tree = tree
return tree.root_node

def parse_tree(self, content: bytes):
"""Parse source content and return the Tree object.

Use this when you need to keep nodes alive across multiple parses
— hold the returned Tree for as long as you hold any Node into it.
"""
tree = self.parser.parse(content)
self._last_tree = tree
return tree

@staticmethod
def get_text(node: Node, source: bytes) -> str:
Expand All @@ -122,13 +148,45 @@ def walk_tree(self, node: Node, source: bytes, callback, depth=0, max_depth=50):
"""
Walk the entire AST tree, calling callback for each node.
Callback signature: callback(node, source, depth) -> bool (True to continue, False to skip children)

Implemented iteratively (issue #116) — the previous recursive form
crashed with SIGSEGV on deeply-nested JS callbacks because Python's
garbage collector could free the parent Tree while child nodes
were still being visited. The iterative form keeps an explicit
stack of (node, depth) tuples and never recurses into Python.

We also disable the cyclic garbage collector for the duration of
the walk. tree-sitter's Python binding owns node memory via the
Tree object; if a GC pass runs mid-walk and collects a transient
cycle that holds the Tree, the nodes on our stack dangle and the
next ``node.children`` access raises SIGSEGV. Disabling gc here is
safe — the walk is bounded by ``max_depth`` and the stack holds
only ``(Node, int)`` tuples, so no unbounded growth is possible.
"""
if depth > max_depth:
return
should_continue = callback(node, source, depth)
if should_continue is not False:
for child in node.children:
self.walk_tree(child, source, callback, depth + 1, max_depth)
# Iterative DFS — (node, depth) tuples. We visit in the same
# pre-order as the recursive version: callback first, then children
# left-to-right.
import gc as _gc
_gc_was_enabled = _gc.isenabled()
if _gc_was_enabled:
_gc.disable()
try:
stack = [(node, depth)]
while stack:
cur, cur_depth = stack.pop()
should_continue = callback(cur, source, cur_depth)
if should_continue is False:
continue
if cur_depth + 1 <= max_depth:
# Push children in reverse so they pop in source order.
children = cur.children
for child in reversed(children):
stack.append((child, cur_depth + 1))
finally:
if _gc_was_enabled:
_gc.enable()

def find_nodes_by_type(self, root: Node, node_type: str) -> List[Node]:
"""Find all nodes of a specific type in the tree."""
Expand Down
15 changes: 15 additions & 0 deletions scripts/outline_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ def _outline_javascript(content: str, detail: str) -> Dict:
"""Outline for JavaScript files."""
outline = {"imports": [], "functions": [], "classes": [], "exports": [], "variables": []}

# Workaround for issue #116: tree-sitter-javascript 0.25 segfaults
# on files with deeply nested callbacks. Use regex fallback for
# files over 100 lines.
line_count = content.count('\n') + 1
if line_count > 100:
_extract_js_outline_regex(content, outline, detail)
return outline

try:
from grammar_loader import get_grammar_loader
loader = get_grammar_loader()
Expand Down Expand Up @@ -297,6 +305,13 @@ def _outline_python(content: str, detail: str) -> Dict:
"""Outline for Python files."""
outline = {"imports": [], "functions": [], "classes": [], "variables": []}

# Workaround for issue #116: tree-sitter-python 0.25 segfaults on
# large files. Use regex fallback for files over 200 lines.
line_count = content.count('\n') + 1
if line_count > 200:
_extract_python_outline_regex(content, outline, detail)
return outline

try:
from grammar_loader import get_grammar_loader
loader = get_grammar_loader()
Expand Down
202 changes: 183 additions & 19 deletions scripts/parsers/js_backend_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,41 +42,194 @@
raise RuntimeError("tree-sitter-javascript not installed")
super().__init__(lang)

def extract_references(self, content: str, file_path: str) -> Dict[str, List[Dict]]:

Check failure on line 45 in scripts/parsers/js_backend_parser.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 58 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8bxOJc3faKAH2til2P&open=AZ8bxOJc3faKAH2til2P&pullRequest=124
"""
Extract function nodes and edges from backend JS.

Returns:
{"nodes": [...], "edges": [...]}

Single-pass recursive walk (issue #116): the previous two-pass
design held ``body_node`` Node references across function
boundaries, which could dangle when tree-sitter's internal
cleanup ran between passes and caused SIGSEGV. This version
uses a single recursive walk that holds the Tree reference in a
local variable for the entire walk duration, processes each
declaration's body immediately, and disables the cyclic GC to
prevent mid-walk collection.

Files larger than ``MAX_SAFE_JS_LINES`` are skipped with a
warning — tree-sitter 0.25 + tree-sitter-javascript 0.25 has a
known segfault on deeply-nested JS callbacks (issue #116) that
cannot be fully mitigated from Python. Skipping large files is
a pragmatic workaround until the binding is upgraded.
"""
import gc as _gc
import logging
_log = logging.getLogger("codelens")

# Workaround for issue #116: tree-sitter-javascript 0.25 has
# nondeterministic segfaults on JS files with deeply nested
# callbacks. Skip them rather than crash the whole scan.
# Threshold is conservative — files under 100 lines rarely have
# deeply nested callbacks that trigger the binding bug.
MAX_SAFE_JS_LINES = 100
line_count = content.count('\n') + 1
if line_count > MAX_SAFE_JS_LINES:
_log.warning(
"Skipping large JS file %s (%d lines > %d threshold) "
"due to tree-sitter segfault risk (issue #116). "
"File will not appear in the graph.",
file_path, line_count, MAX_SAFE_JS_LINES,
)
return {"nodes": [], "edges": []}

_gc_was_enabled = _gc.isenabled()
if _gc_was_enabled:
_gc.disable()
try:
source = content.encode('utf-8')
# Use parse_tree so we hold the Tree object directly —
# root_node references stay valid only while the Tree is live.
tree_obj = self.parse_tree(source)
root = tree_obj.root_node

nodes: List[Dict] = []
edges: List[Dict] = []

MAX_DEPTH = 200

def _walk(node: Node, depth: int):
"""Recursive walk — body Node references never leave
this function's frame, so they cannot dangle across
function-boundary crossings."""
if depth > MAX_DEPTH:
return

# Detect export_statement wrapper and mark exported
if node.type == 'export_statement':
for child in node.children:
if child.type in ('function_declaration', 'generator_function_declaration'):
self._parse_and_collect_calls(
child, source, file_path, nodes, edges,
exported=True,
)
elif child.type == 'class_declaration':
self._parse_and_collect_calls(
child, source, file_path, nodes, edges,
exported=True,
)

Check warning on line 121 in scripts/parsers/js_backend_parser.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either merge this branch with the identical one on line "113" or change one of the implementations.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8bxOJc3faKAH2til2Q&open=AZ8bxOJc3faKAH2til2Q&pullRequest=124
elif child.type == 'lexical_declaration':
for subchild in child.children:
if subchild.type == 'variable_declarator':
self._parse_and_collect_calls(
subchild, source, file_path, nodes, edges,
exported=True,
)
elif child.type == 'default_export_clause':
for subchild in node.children:
if subchild.type == 'class_declaration':
self._parse_and_collect_calls(
subchild, source, file_path, nodes, edges,
exported=True,
)
elif subchild.type in ('function_declaration', 'generator_function_declaration'):
self._parse_and_collect_calls(
subchild, source, file_path, nodes, edges,
exported=True,
)

Check warning on line 140 in scripts/parsers/js_backend_parser.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either merge this branch with the identical one on line "132" or change one of the implementations.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8bxOJc3faKAH2til2R&open=AZ8bxOJc3faKAH2til2R&pullRequest=124
return # Don't double-count by recursing inside export_statement

if node.type == 'function_declaration' or node.type == 'generator_function_declaration':
self._parse_and_collect_calls(
node, source, file_path, nodes, edges,
)

elif node.type == 'variable_declarator':
# Skip if parent is lexical_declaration inside export_statement
parent = node.parent
if parent and parent.type == 'lexical_declaration':
grandparent = parent.parent
if grandparent and grandparent.type == 'export_statement':
# Already handled via export_statement branch above
pass
else:
self._parse_and_collect_calls(
node, source, file_path, nodes, edges,
)
else:
self._parse_and_collect_calls(
node, source, file_path, nodes, edges,
)

elif node.type == 'class_declaration':
self._parse_and_collect_calls(
node, source, file_path, nodes, edges,
)

Check warning on line 168 in scripts/parsers/js_backend_parser.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either merge this branch with the identical one on line "144" or change one of the implementations.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8bxOJc3faKAH2til2S&open=AZ8bxOJc3faKAH2til2S&pullRequest=124

# Recurse into children to find nested declarations
for child in node.children:
_walk(child, depth + 1)

_walk(root, 0)
return {"nodes": nodes, "edges": edges}
finally:
if _gc_was_enabled:
_gc.enable()

def _parse_and_collect_calls(
self,
decl_node: Node,
source: bytes,
file_path: str,
nodes: List[Dict],
edges: List[Dict],
exported: bool = False,
) -> Optional[Dict]:
"""Parse a declaration node and immediately collect call edges
from its body.

Combines :meth:`_parse_function_decl` /
:meth:`_parse_variable_declarator` /
:meth:`_parse_class_decl` with :meth:`_find_calls_in_scope` so
body Node references never leave this method (issue #116).
"""
source = content.encode('utf-8')
tree = self.parse(source)
if decl_node.type in ('function_declaration', 'generator_function_declaration'):
decl_info = self._parse_function_decl(decl_node, source, file_path)
elif decl_node.type == 'variable_declarator':
decl_info = self._parse_variable_declarator(decl_node, source, file_path)
elif decl_node.type == 'class_declaration':
decl_info = self._parse_class_decl(decl_node, source, file_path)
else:
return None

nodes = []
edges = []
if not decl_info:
return None

# First pass: find all function declarations
fn_declarations = self._find_function_declarations(tree, source, file_path)
if exported:
decl_info["node"]["exported"] = True

# Build a map of line → function for scope resolution
fn_scope_map = self._build_scope_map(fn_declarations)
nodes.append(decl_info["node"])

# Second pass: find all function calls within each scope
for decl in fn_declarations:
nodes.append(decl["node"])
# Find calls within this function's body
fn_calls = self._find_calls_in_scope(decl["body_node"], source, file_path)
# Immediately collect calls from the body — body_node is still
# valid here because we're inside the same recursive walk frame
# and the Tree is held in the outer extract_references scope.
body_node = decl_info.get("body_node")
if body_node is not None:
fn_calls = self._find_calls_in_scope(body_node, source, file_path)
for call_info in fn_calls:
edge = {
"from": decl["node"]["id"],
"from": decl_info["node"]["id"],
"to_fn": call_info["fn_name"],
"via_self": call_info.get("via_self", False)
}
if call_info.get("is_ipc_call"):
edge["is_ipc_call"] = True
edges.append(edge)

return {"nodes": nodes, "edges": edges}
# Don't return body_node — let it be GC'd
decl_info.pop("body_node", None)
return decl_info

def _find_function_declarations(self, root: Node, source: bytes,
file_path: str) -> List[Dict]:
Expand Down Expand Up @@ -282,13 +435,22 @@

def _find_calls_in_scope(self, body_node: Optional[Node], source: bytes,
file_path: str) -> List[Dict]:
"""Find all function calls within a function body."""
"""Find all function calls within a function body.

Uses a local recursive walk (issue #116) instead of
:meth:`self.walk_tree` so body Node references never cross a
function boundary — the body subtree is fully processed before
this method returns.
"""
if not body_node:
return []

calls = []
calls: List[Dict] = []
MAX_DEPTH = 200

def visit(node: Node, _, depth):
def _walk_calls(node: Node, depth: int):
if depth > MAX_DEPTH:
return
if node.type == 'call_expression':
call_info = self._parse_call(node, source)
if call_info:
Expand All @@ -297,8 +459,10 @@
call_info = self._parse_new_expression(node, source)
if call_info:
calls.append(call_info)
for child in node.children:
_walk_calls(child, depth + 1)

self.walk_tree(body_node, source, visit)
_walk_calls(body_node, 0)
return calls

def _parse_call(self, node: Node, source: bytes) -> Optional[Dict]:
Expand Down
Loading
Loading