Navegador

navegador / navegador / ingestion / bash.py
Blame History Raw 264 lines
1
"""
2
Bash/Shell script parser — extracts functions, top-level variables,
3
source/. imports, and call edges from .sh/.bash files using tree-sitter.
4
"""
5
6
import logging
7
from pathlib import Path
8
9
from navegador.graph.schema import EdgeType, NodeLabel
10
from navegador.graph.store import GraphStore
11
from navegador.ingestion.parser import LanguageParser
12
13
logger = logging.getLogger(__name__)
14
15
16
def _get_bash_language():
17
try:
18
import tree_sitter_bash as tsbash # type: ignore[import]
19
from tree_sitter import Language
20
21
return Language(tsbash.language())
22
except ImportError as e:
23
raise ImportError("Install tree-sitter-bash: pip install tree-sitter-bash") from e
24
25
26
def _node_text(node, source: bytes) -> str:
27
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
28
29
30
class BashParser(LanguageParser):
31
"""Parses Bash/Shell script files into the navegador graph."""
32
33
def __init__(self) -> None:
34
from tree_sitter import Parser # type: ignore[import]
35
36
self._parser = Parser(_get_bash_language())
37
38
def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]:
39
source = path.read_bytes()
40
tree = self._parser.parse(source)
41
rel_path = str(path.relative_to(repo_root))
42
43
store.create_node(
44
NodeLabel.File,
45
{
46
"name": path.name,
47
"path": rel_path,
48
"language": "bash",
49
"line_count": source.count(b"\n"),
50
},
51
)
52
53
stats = {"functions": 0, "classes": 0, "edges": 0}
54
self._walk(tree.root_node, source, rel_path, store, stats)
55
return stats
56
57
# ── AST walker ────────────────────────────────────────────────────────────
58
59
def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None:
60
if node.type == "function_definition":
61
self._handle_function(node, source, file_path, store, stats)
62
return
63
if node.type == "variable_assignment":
64
self._handle_variable(node, source, file_path, store, stats)
65
return
66
if node.type == "command":
67
self._handle_command(node, source, file_path, store, stats)
68
return
69
for child in node.children:
70
self._walk(child, source, file_path, store, stats)
71
72
# ── Handlers ──────────────────────────────────────────────────────────────
73
74
def _handle_function(
75
self,
76
node,
77
source: bytes,
78
file_path: str,
79
store: GraphStore,
80
stats: dict,
81
) -> None:
82
name_node = node.child_by_field_name("name")
83
if not name_node:
84
return
85
name = _node_text(name_node, source)
86
87
store.create_node(
88
NodeLabel.Function,
89
{
90
"name": name,
91
"file_path": file_path,
92
"line_start": node.start_point[0] + 1,
93
"line_end": node.end_point[0] + 1,
94
"docstring": "",
95
"semantic_type": "shell_function",
96
},
97
)
98
99
store.create_edge(
100
NodeLabel.File,
101
{"path": file_path},
102
EdgeType.CONTAINS,
103
NodeLabel.Function,
104
{"name": name, "file_path": file_path},
105
)
106
stats["functions"] += 1
107
stats["edges"] += 1
108
109
self._extract_calls(node, source, file_path, name, store, stats)
110
111
def _handle_variable(
112
self,
113
node,
114
source: bytes,
115
file_path: str,
116
store: GraphStore,
117
stats: dict,
118
) -> None:
119
# Only track top-level variable assignments (parent is program)
120
if node.parent is None or node.parent.type not in ("program", "source_file"):
121
return
122
123
name_node = node.child_by_field_name("name")
124
if not name_node:
125
return
126
name = _node_text(name_node, source)
127
128
value_node = node.child_by_field_name("value")
129
value = _node_text(value_node, source) if value_node else ""
130
131
store.create_node(
132
NodeLabel.Variable,
133
{
134
"name": name,
135
"file_path": file_path,
136
"line_start": node.start_point[0] + 1,
137
"line_end": node.end_point[0] + 1,
138
"semantic_type": "shell_variable",
139
"value": value,
140
},
141
)
142
143
store.create_edge(
144
NodeLabel.File,
145
{"path": file_path},
146
EdgeType.CONTAINS,
147
NodeLabel.Variable,
148
{"name": name, "file_path": file_path},
149
)
150
stats["edges"] += 1
151
152
def _handle_command(
153
self,
154
node,
155
source: bytes,
156
file_path: str,
157
store: GraphStore,
158
stats: dict,
159
) -> None:
160
"""Handle source/. commands as imports."""
161
name_node = node.child_by_field_name("name")
162
if not name_node:
163
return
164
cmd_name = _node_text(name_node, source)
165
166
# Only handle source and . (dot-source) commands
167
if cmd_name not in ("source", "."):
168
return
169
170
# The sourced file path is the first argument
171
arg_types = ("word", "string", "raw_string", "concatenation")
172
args = [child for child in node.children if child != name_node and child.type in arg_types]
173
if not args:
174
return
175
sourced_path = _node_text(args[0], source).strip("'\"")
176
177
store.create_node(
178
NodeLabel.Import,
179
{
180
"name": sourced_path,
181
"file_path": file_path,
182
"line_start": node.start_point[0] + 1,
183
"module": sourced_path,
184
"semantic_type": "shell_source",
185
},
186
)
187
188
store.create_edge(
189
NodeLabel.File,
190
{"path": file_path},
191
EdgeType.IMPORTS,
192
NodeLabel.Import,
193
{"name": sourced_path, "file_path": file_path},
194
)
195
stats["edges"] += 1
196
197
# ── Call extraction ───────────────────────────────────────────────────────
198
199
def _extract_calls(
200
self,
201
fn_node,
202
source: bytes,
203
file_path: str,
204
fn_name: str,
205
store: GraphStore,
206
stats: dict,
207
) -> None:
208
def walk(node):
209
if node.type == "command":
210
name_node = node.child_by_field_name("name")
211
if name_node:
212
callee = _node_text(name_node, source)
213
# Skip builtins and source commands — only track function calls
214
if callee not in (
215
"source",
216
".",
217
"echo",
218
"printf",
219
"cd",
220
"exit",
221
"return",
222
"export",
223
"local",
224
"readonly",
225
"declare",
226
"typeset",
227
"unset",
228
"shift",
229
"set",
230
"eval",
231
"exec",
232
"test",
233
"[",
234
"[[",
235
"true",
236
"false",
237
":",
238
"read",
239
"if",
240
"then",
241
"else",
242
"fi",
243
"for",
244
"while",
245
"do",
246
"done",
247
"case",
248
"esac",
249
):
250
store.create_edge(
251
NodeLabel.Function,
252
{"name": fn_name, "file_path": file_path},
253
EdgeType.CALLS,
254
NodeLabel.Function,
255
{"name": callee, "file_path": file_path},
256
)
257
stats["edges"] += 1
258
for child in node.children:
259
walk(child)
260
261
body = fn_node.child_by_field_name("body")
262
if body:
263
walk(body)
264

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button