Navegador

navegador / tests / test_bash_parser.py
Blame History Raw 535 lines
1
"""Tests for navegador.ingestion.bash — BashParser internal methods."""
2
3
from unittest.mock import MagicMock, patch
4
5
import pytest
6
7
from navegador.graph.schema import EdgeType, NodeLabel
8
9
10
class MockNode:
11
_id_counter = 0
12
13
def __init__(
14
self,
15
type_: str,
16
text: bytes = b"",
17
children: list = None,
18
start_byte: int = 0,
19
end_byte: int = 0,
20
start_point: tuple = (0, 0),
21
end_point: tuple = (0, 0),
22
parent=None,
23
):
24
MockNode._id_counter += 1
25
self.id = MockNode._id_counter
26
self.type = type_
27
self._text = text
28
self.children = children or []
29
self.start_byte = start_byte
30
self.end_byte = end_byte
31
self.start_point = start_point
32
self.end_point = end_point
33
self.parent = parent
34
self._fields: dict = {}
35
for child in self.children:
36
child.parent = self
37
38
def child_by_field_name(self, name: str):
39
return self._fields.get(name)
40
41
def set_field(self, name: str, node):
42
self._fields[name] = node
43
node.parent = self
44
return self
45
46
47
def _text_node(text: bytes, type_: str = "identifier") -> MockNode:
48
return MockNode(type_, text, start_byte=0, end_byte=len(text))
49
50
51
def _make_store():
52
store = MagicMock()
53
store.query.return_value = MagicMock(result_set=[])
54
return store
55
56
57
def _make_parser():
58
from navegador.ingestion.bash import BashParser
59
60
parser = BashParser.__new__(BashParser)
61
parser._parser = MagicMock()
62
return parser
63
64
65
class TestBashGetLanguage:
66
def test_raises_when_not_installed(self):
67
from navegador.ingestion.bash import _get_bash_language
68
69
with patch.dict(
70
"sys.modules",
71
{
72
"tree_sitter_bash": None,
73
"tree_sitter": None,
74
},
75
):
76
with pytest.raises(ImportError, match="tree-sitter-bash"):
77
_get_bash_language()
78
79
def test_returns_language_object(self):
80
from navegador.ingestion.bash import _get_bash_language
81
82
mock_tsbash = MagicMock()
83
mock_ts = MagicMock()
84
with patch.dict(
85
"sys.modules",
86
{
87
"tree_sitter_bash": mock_tsbash,
88
"tree_sitter": mock_ts,
89
},
90
):
91
result = _get_bash_language()
92
assert result is mock_ts.Language.return_value
93
94
95
class TestBashNodeText:
96
def test_extracts_bytes(self):
97
from navegador.ingestion.bash import _node_text
98
99
source = b"#!/bin/bash\nmy_func() {"
100
node = MockNode(
101
"identifier",
102
start_byte=12,
103
end_byte=19,
104
)
105
assert _node_text(node, source) == "my_func"
106
107
108
class TestBashHandleFunction:
109
def test_creates_function_node(self):
110
parser = _make_parser()
111
store = _make_store()
112
source = b"deploy"
113
name_node = MockNode(
114
"word",
115
start_byte=0,
116
end_byte=6,
117
)
118
node = MockNode(
119
"function_definition",
120
start_point=(0, 0),
121
end_point=(5, 1),
122
)
123
node.set_field("name", name_node)
124
stats = {"functions": 0, "classes": 0, "edges": 0}
125
parser._handle_function(node, source, "deploy.sh", store, stats)
126
assert stats["functions"] == 1
127
assert stats["edges"] == 1
128
label = store.create_node.call_args[0][0]
129
props = store.create_node.call_args[0][1]
130
assert label == NodeLabel.Function
131
assert props["name"] == "deploy"
132
assert props["semantic_type"] == "shell_function"
133
134
def test_skips_if_no_name_node(self):
135
parser = _make_parser()
136
store = _make_store()
137
node = MockNode(
138
"function_definition",
139
start_point=(0, 0),
140
end_point=(0, 5),
141
)
142
stats = {"functions": 0, "classes": 0, "edges": 0}
143
parser._handle_function(node, b"", "test.sh", store, stats)
144
assert stats["functions"] == 0
145
store.create_node.assert_not_called()
146
147
def test_extracts_calls_from_body(self):
148
parser = _make_parser()
149
store = _make_store()
150
source = b"deploy helper"
151
name_node = MockNode(
152
"word",
153
start_byte=0,
154
end_byte=6,
155
)
156
callee_name = MockNode(
157
"word",
158
start_byte=7,
159
end_byte=13,
160
)
161
cmd = MockNode("command")
162
cmd.set_field("name", callee_name)
163
body = MockNode(
164
"compound_statement",
165
children=[cmd],
166
)
167
node = MockNode(
168
"function_definition",
169
start_point=(0, 0),
170
end_point=(5, 1),
171
)
172
node.set_field("name", name_node)
173
node.set_field("body", body)
174
stats = {"functions": 0, "classes": 0, "edges": 0}
175
parser._handle_function(node, source, "deploy.sh", store, stats)
176
# 1 CONTAINS edge + 1 CALLS edge
177
assert stats["edges"] == 2
178
179
180
class TestBashHandleVariable:
181
def test_creates_variable_node_for_top_level(self):
182
parser = _make_parser()
183
store = _make_store()
184
source = b'VERSION="1.0"'
185
name_node = MockNode(
186
"variable_name",
187
start_byte=0,
188
end_byte=7,
189
)
190
value_node = MockNode(
191
"string",
192
start_byte=8,
193
end_byte=13,
194
)
195
program = MockNode("program")
196
node = MockNode(
197
"variable_assignment",
198
start_point=(0, 0),
199
end_point=(0, 13),
200
parent=program,
201
)
202
node.set_field("name", name_node)
203
node.set_field("value", value_node)
204
# Re-set parent after construction since constructor
205
# overwrites it
206
node.parent = program
207
stats = {"functions": 0, "classes": 0, "edges": 0}
208
parser._handle_variable(node, source, "env.sh", store, stats)
209
assert stats["edges"] == 1
210
label = store.create_node.call_args[0][0]
211
props = store.create_node.call_args[0][1]
212
assert label == NodeLabel.Variable
213
assert props["name"] == "VERSION"
214
assert props["semantic_type"] == "shell_variable"
215
216
def test_skips_non_top_level_variable(self):
217
parser = _make_parser()
218
store = _make_store()
219
source = b"x=1"
220
name_node = MockNode(
221
"variable_name",
222
start_byte=0,
223
end_byte=1,
224
)
225
func_parent = MockNode("function_definition")
226
node = MockNode(
227
"variable_assignment",
228
start_point=(0, 0),
229
end_point=(0, 3),
230
parent=func_parent,
231
)
232
node.set_field("name", name_node)
233
node.parent = func_parent
234
stats = {"functions": 0, "classes": 0, "edges": 0}
235
parser._handle_variable(node, source, "test.sh", store, stats)
236
assert stats["edges"] == 0
237
store.create_node.assert_not_called()
238
239
def test_skips_variable_without_name(self):
240
parser = _make_parser()
241
store = _make_store()
242
program = MockNode("program")
243
node = MockNode(
244
"variable_assignment",
245
start_point=(0, 0),
246
end_point=(0, 3),
247
parent=program,
248
)
249
node.parent = program
250
stats = {"functions": 0, "classes": 0, "edges": 0}
251
parser._handle_variable(node, b"", "test.sh", store, stats)
252
store.create_node.assert_not_called()
253
254
255
class TestBashHandleSource:
256
def test_creates_import_for_source_command(self):
257
parser = _make_parser()
258
store = _make_store()
259
source = b"source ./lib.sh"
260
name_node = MockNode(
261
"word",
262
start_byte=0,
263
end_byte=6,
264
)
265
arg_node = MockNode(
266
"word",
267
start_byte=7,
268
end_byte=15,
269
)
270
node = MockNode(
271
"command",
272
children=[name_node, arg_node],
273
start_point=(0, 0),
274
end_point=(0, 15),
275
)
276
node.set_field("name", name_node)
277
stats = {"functions": 0, "classes": 0, "edges": 0}
278
parser._handle_command(node, source, "main.sh", store, stats)
279
assert stats["edges"] == 1
280
label = store.create_node.call_args[0][0]
281
props = store.create_node.call_args[0][1]
282
assert label == NodeLabel.Import
283
assert props["name"] == "./lib.sh"
284
assert props["semantic_type"] == "shell_source"
285
286
def test_creates_import_for_dot_command(self):
287
parser = _make_parser()
288
store = _make_store()
289
source = b". /etc/profile"
290
name_node = MockNode(
291
"word",
292
start_byte=0,
293
end_byte=1,
294
)
295
arg_node = MockNode(
296
"word",
297
start_byte=2,
298
end_byte=14,
299
)
300
node = MockNode(
301
"command",
302
children=[name_node, arg_node],
303
start_point=(0, 0),
304
end_point=(0, 14),
305
)
306
node.set_field("name", name_node)
307
stats = {"functions": 0, "classes": 0, "edges": 0}
308
parser._handle_command(node, source, "main.sh", store, stats)
309
assert stats["edges"] == 1
310
props = store.create_node.call_args[0][1]
311
assert props["name"] == "/etc/profile"
312
313
def test_ignores_non_source_commands(self):
314
parser = _make_parser()
315
store = _make_store()
316
source = b"echo hello"
317
name_node = MockNode(
318
"word",
319
start_byte=0,
320
end_byte=4,
321
)
322
node = MockNode(
323
"command",
324
children=[name_node],
325
start_point=(0, 0),
326
end_point=(0, 10),
327
)
328
node.set_field("name", name_node)
329
stats = {"functions": 0, "classes": 0, "edges": 0}
330
parser._handle_command(node, source, "main.sh", store, stats)
331
assert stats["edges"] == 0
332
store.create_node.assert_not_called()
333
334
def test_skips_source_without_arguments(self):
335
parser = _make_parser()
336
store = _make_store()
337
source = b"source"
338
name_node = MockNode(
339
"word",
340
start_byte=0,
341
end_byte=6,
342
)
343
node = MockNode(
344
"command",
345
children=[name_node],
346
start_point=(0, 0),
347
end_point=(0, 6),
348
)
349
node.set_field("name", name_node)
350
stats = {"functions": 0, "classes": 0, "edges": 0}
351
parser._handle_command(node, source, "main.sh", store, stats)
352
assert stats["edges"] == 0
353
store.create_node.assert_not_called()
354
355
356
class TestBashExtractCalls:
357
def test_finds_command_calls(self):
358
parser = _make_parser()
359
store = _make_store()
360
source = b"build_app"
361
callee = MockNode(
362
"word",
363
start_byte=0,
364
end_byte=9,
365
)
366
cmd = MockNode("command")
367
cmd.set_field("name", callee)
368
body = MockNode(
369
"compound_statement",
370
children=[cmd],
371
)
372
fn_node = MockNode("function_definition")
373
fn_node.set_field("body", body)
374
stats = {"functions": 0, "classes": 0, "edges": 0}
375
parser._extract_calls(fn_node, source, "deploy.sh", "deploy", store, stats)
376
assert stats["edges"] == 1
377
edge_call = store.create_edge.call_args[0]
378
assert edge_call[2] == EdgeType.CALLS
379
assert edge_call[4]["name"] == "build_app"
380
381
def test_skips_builtins(self):
382
parser = _make_parser()
383
store = _make_store()
384
source = b"echo"
385
callee = MockNode(
386
"word",
387
start_byte=0,
388
end_byte=4,
389
)
390
cmd = MockNode("command")
391
cmd.set_field("name", callee)
392
body = MockNode(
393
"compound_statement",
394
children=[cmd],
395
)
396
fn_node = MockNode("function_definition")
397
fn_node.set_field("body", body)
398
stats = {"functions": 0, "classes": 0, "edges": 0}
399
parser._extract_calls(fn_node, source, "test.sh", "myfunc", store, stats)
400
assert stats["edges"] == 0
401
402
def test_no_calls_in_empty_body(self):
403
parser = _make_parser()
404
store = _make_store()
405
fn_node = MockNode("function_definition")
406
fn_node.set_field("body", MockNode("compound_statement"))
407
stats = {"functions": 0, "classes": 0, "edges": 0}
408
parser._extract_calls(fn_node, b"", "test.sh", "myfunc", store, stats)
409
assert stats["edges"] == 0
410
411
def test_no_body_means_no_calls(self):
412
parser = _make_parser()
413
store = _make_store()
414
fn_node = MockNode("function_definition")
415
stats = {"functions": 0, "classes": 0, "edges": 0}
416
parser._extract_calls(fn_node, b"", "test.sh", "myfunc", store, stats)
417
assert stats["edges"] == 0
418
419
420
class TestBashWalkDispatch:
421
def test_walk_handles_function_definition(self):
422
parser = _make_parser()
423
store = _make_store()
424
source = b"deploy"
425
name_node = MockNode(
426
"word",
427
start_byte=0,
428
end_byte=6,
429
)
430
fn = MockNode(
431
"function_definition",
432
start_point=(0, 0),
433
end_point=(5, 1),
434
)
435
fn.set_field("name", name_node)
436
root = MockNode("program", children=[fn])
437
stats = {"functions": 0, "classes": 0, "edges": 0}
438
parser._walk(root, source, "deploy.sh", store, stats)
439
assert stats["functions"] == 1
440
441
def test_walk_handles_variable_assignment(self):
442
parser = _make_parser()
443
store = _make_store()
444
source = b"VERSION"
445
name_node = MockNode(
446
"variable_name",
447
start_byte=0,
448
end_byte=7,
449
)
450
program = MockNode("program")
451
var = MockNode(
452
"variable_assignment",
453
start_point=(0, 0),
454
end_point=(0, 13),
455
)
456
var.set_field("name", name_node)
457
program.children = [var]
458
for child in program.children:
459
child.parent = program
460
stats = {"functions": 0, "classes": 0, "edges": 0}
461
parser._walk(program, source, "env.sh", store, stats)
462
assert stats["edges"] == 1
463
464
def test_walk_handles_source_command(self):
465
parser = _make_parser()
466
store = _make_store()
467
source = b"source ./lib.sh"
468
name_node = MockNode(
469
"word",
470
start_byte=0,
471
end_byte=6,
472
)
473
arg_node = MockNode(
474
"word",
475
start_byte=7,
476
end_byte=15,
477
)
478
cmd = MockNode(
479
"command",
480
children=[name_node, arg_node],
481
start_point=(0, 0),
482
end_point=(0, 15),
483
)
484
cmd.set_field("name", name_node)
485
root = MockNode("program", children=[cmd])
486
stats = {"functions": 0, "classes": 0, "edges": 0}
487
parser._walk(root, source, "main.sh", store, stats)
488
assert stats["edges"] == 1
489
490
def test_walk_recurses_into_children(self):
491
parser = _make_parser()
492
store = _make_store()
493
source = b"deploy"
494
name_node = MockNode(
495
"word",
496
start_byte=0,
497
end_byte=6,
498
)
499
fn = MockNode(
500
"function_definition",
501
start_point=(0, 0),
502
end_point=(5, 1),
503
)
504
fn.set_field("name", name_node)
505
wrapper = MockNode("if_statement", children=[fn])
506
root = MockNode("program", children=[wrapper])
507
stats = {"functions": 0, "classes": 0, "edges": 0}
508
parser._walk(root, source, "deploy.sh", store, stats)
509
assert stats["functions"] == 1
510
511
512
class TestBashParseFile:
513
def test_creates_file_node(self):
514
import tempfile
515
from pathlib import Path
516
517
parser = _make_parser()
518
store = _make_store()
519
mock_tree = MagicMock()
520
mock_tree.root_node.type = "program"
521
mock_tree.root_node.children = []
522
parser._parser.parse.return_value = mock_tree
523
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
524
f.write(b"#!/bin/bash\necho hello\n")
525
fpath = Path(f.name)
526
try:
527
parser.parse_file(fpath, fpath.parent, store)
528
store.create_node.assert_called_once()
529
label = store.create_node.call_args[0][0]
530
props = store.create_node.call_args[0][1]
531
assert label == NodeLabel.File
532
assert props["language"] == "bash"
533
finally:
534
fpath.unlink()
535

Keyboard Shortcuts

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