bash-dap 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bash_dap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """bash-dap: a debug adapter of the Debug Adapter Protocol for bash scripts."""
2
+
3
+ __version__ = "0.1"
bash_dap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from bash_dap.adapter import main
4
+
5
+ sys.exit(main())
bash_dap/adapter.py ADDED
@@ -0,0 +1,537 @@
1
+ """bash-dap: a debug adapter of the Debug Adapter Protocol for bash scripts.
2
+
3
+ The adapter speaks DAP on its stdin and stdout. It runs the script under harness.sh, a debugger
4
+ of bash written in bash with the trap DEBUG, and talks to it through two fifos and a few files
5
+ of a directory of its own (see harness.sh). Nothing but bash and Python is needed.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import threading
16
+ import time
17
+
18
+ HARNESS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "harness.sh")
19
+ VARIABLES_REF = 1 # the reference of the variables of a stop
20
+
21
+
22
+ def unquote(text):
23
+ """The string that printf %q of bash has quoted: \\x, '...' and $'...' (C escapes)."""
24
+ out = []
25
+ i, n = 0, len(text)
26
+ escapes = {"n": "\n", "t": "\t", "r": "\r", "a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b",
27
+ "f": "\f", "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?"}
28
+ while i < n:
29
+ c = text[i]
30
+ if c == "\\" and i + 1 < n:
31
+ out.append(text[i + 1])
32
+ i += 2
33
+ elif c == "'":
34
+ j = text.find("'", i + 1)
35
+ j = n if j < 0 else j
36
+ out.append(text[i + 1:j])
37
+ i = j + 1
38
+ elif c == "$" and text.startswith("$'", i):
39
+ i += 2
40
+ while i < n and text[i] != "'":
41
+ if text[i] == "\\" and i + 1 < n:
42
+ e = text[i + 1]
43
+ if e in escapes:
44
+ out.append(escapes[e])
45
+ i += 2
46
+ elif e == "x":
47
+ m = re.match(r"[0-9a-fA-F]{1,2}", text[i + 2:])
48
+ out.append(chr(int(m.group(0), 16)) if m else "x")
49
+ i += 2 + (len(m.group(0)) if m else 0)
50
+ elif e in "01234567":
51
+ m = re.match(r"[0-7]{1,3}", text[i + 1:])
52
+ out.append(chr(int(m.group(0), 8)))
53
+ i += 1 + len(m.group(0))
54
+ else:
55
+ out.append(e)
56
+ i += 2
57
+ else:
58
+ out.append(text[i])
59
+ i += 1
60
+ i += 1
61
+ else:
62
+ out.append(c)
63
+ i += 1
64
+ return "".join(out)
65
+
66
+
67
+ def unquote_double(text):
68
+ """A word of bash in double quotes, as declare -p writes a value: "a \\"b\\" \\$c"."""
69
+ if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
70
+ return re.sub(r'\\(["\\$`])', r"\1", text[1:-1])
71
+ if text.startswith("$'"):
72
+ return unquote(text)
73
+ return text
74
+
75
+
76
+ def parse_declare(text):
77
+ """What declare -p says of a variable: its flags, and a value or the elements of an array
78
+ as (key, value) pairs; None for something else, an error say."""
79
+ m = re.match(r"declare -(\S+) ([^=\s]+)(?:=(.*))?$", text, re.S)
80
+ if not m:
81
+ return None
82
+ flags, value = m.group(1), m.group(3)
83
+ if value is None:
84
+ return flags, "", None
85
+ if ("a" in flags or "A" in flags) and value.startswith("("):
86
+ pairs = re.findall(r'\[((?:[^\]\\]|\\.)*)\]=("(?:[^"\\]|\\.)*"|\$\'(?:[^\'\\]|\\.)*\'|[^\s)]*)',
87
+ value[1:])
88
+ return flags, value, [(unquote_double(k) if k.startswith('"') else k, unquote_double(v))
89
+ for k, v in pairs]
90
+ return flags, unquote_double(value), None
91
+
92
+
93
+ class Adapter:
94
+ def __init__(self, rfile, wfile):
95
+ self.rfile = rfile
96
+ self.wfile = wfile
97
+ self.write_lock = threading.Lock()
98
+ self.seq = 0
99
+ self.client = {}
100
+ self.dir = None
101
+ self.proc = None
102
+ self.pid = None # of the script run in the terminal of the client
103
+ self.commands = None
104
+ self.cv = threading.Condition()
105
+ self.collect = []
106
+ self.done = False
107
+ self.stopped = False
108
+ self.alive = False
109
+ self.pause_asked = False
110
+ self.breaks = {}
111
+ self.gen = 0
112
+ self.refs = {}
113
+ self.next_ref = VARIABLES_REF + 1
114
+ self.callbacks = {}
115
+ self.stop_on_entry = False
116
+ self.configured = False
117
+ self.entered = False
118
+
119
+ # --- the protocol ------------------------------------------------------------------------
120
+
121
+ def send(self, message):
122
+ with self.write_lock:
123
+ self.seq += 1
124
+ message["seq"] = self.seq
125
+ body = json.dumps(message).encode()
126
+ self.wfile.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
127
+ self.wfile.flush()
128
+ return self.seq
129
+
130
+ def event(self, name, body=None):
131
+ message = {"type": "event", "event": name}
132
+ if body is not None:
133
+ message["body"] = body
134
+ self.send(message)
135
+
136
+ def respond(self, request, body=None, success=True, message=None):
137
+ response = {"type": "response", "request_seq": request["seq"],
138
+ "command": request["command"], "success": success}
139
+ if body is not None:
140
+ response["body"] = body
141
+ if message is not None:
142
+ response["message"] = message
143
+ self.send(response)
144
+
145
+ def output(self, text, category="console"):
146
+ self.event("output", {"category": category, "output": text})
147
+
148
+ def read_message(self):
149
+ length = None
150
+ while True:
151
+ line = self.rfile.readline()
152
+ if not line:
153
+ return None
154
+ line = line.strip()
155
+ if not line:
156
+ if length is not None:
157
+ break
158
+ continue
159
+ name, _, value = line.partition(b":")
160
+ if name.strip().lower() == b"content-length":
161
+ length = int(value)
162
+ return json.loads(self.rfile.read(length))
163
+
164
+ # --- the harness -------------------------------------------------------------------------
165
+
166
+ def tell(self, command):
167
+ os.write(self.commands, (command + "\n").encode())
168
+
169
+ def ask(self, command, timeout=10):
170
+ """A command of the harness while the script is stopped: the lines of its reply."""
171
+ if not self.stopped or self.commands is None:
172
+ return None
173
+ with self.cv:
174
+ self.collect = []
175
+ self.done = False
176
+ self.tell(command)
177
+ end = time.monotonic() + timeout
178
+ while not self.done and self.alive and time.monotonic() < end:
179
+ self.cv.wait(0.2)
180
+ return list(self.collect) if self.done else None
181
+
182
+ def resume(self, command):
183
+ if not self.stopped:
184
+ return False
185
+ self.stopped = False
186
+ self.refs = {}
187
+ self.tell(command)
188
+ return True
189
+
190
+ def save_breaks(self):
191
+ """The breakpoints for the harness: the processes of the script take them at their next
192
+ command, whether the script runs or is stopped."""
193
+ if self.dir is None:
194
+ return
195
+ with open(os.path.join(self.dir, "breaks"), "w") as f:
196
+ for path, lines in self.breaks.items():
197
+ for line in lines:
198
+ f.write("%s:%d\n" % (path, line))
199
+ self.gen += 1
200
+ open(os.path.join(self.dir, "gen.%d" % self.gen), "w").close()
201
+
202
+ def open_fifos(self):
203
+ """The fifos once the harness has opened its ends; False when it never does."""
204
+ path = os.path.join(self.dir, "commands")
205
+ end = time.monotonic() + 15
206
+ while True:
207
+ try:
208
+ fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
209
+ break
210
+ except OSError:
211
+ if time.monotonic() > end or (self.proc is not None and self.proc.poll() is not None):
212
+ return None
213
+ time.sleep(0.02)
214
+ os.set_blocking(fd, True)
215
+ self.commands = fd
216
+ return open(os.path.join(self.dir, "replies"), "r")
217
+
218
+ def harness_loop(self):
219
+ replies = self.open_fifos()
220
+ if replies is None:
221
+ self.output("bash-dap: the script did not start\n", "stderr")
222
+ self.alive = False
223
+ self.event("terminated")
224
+ return
225
+ self.alive = True
226
+ for line in replies:
227
+ line = line.rstrip("\n")
228
+ kind, _, rest = line.partition(" ")
229
+ if kind == "stopped":
230
+ self.on_stopped(rest)
231
+ elif kind == "exited":
232
+ self.alive = False
233
+ self.event("exited", {"exitCode": int(rest or 0)})
234
+ break
235
+ elif kind == "done":
236
+ with self.cv:
237
+ self.done = True
238
+ self.cv.notify_all()
239
+ else:
240
+ with self.cv:
241
+ self.collect.append((kind, rest))
242
+ self.alive = False
243
+ with self.cv:
244
+ self.cv.notify_all()
245
+ self.event("terminated")
246
+
247
+ def on_stopped(self, rest):
248
+ reason, line, func, path = (rest.split(" ", 3) + ["", "", "", ""])[:4]
249
+ self.stopped = True
250
+ self.refs = {}
251
+ if reason == "entry":
252
+ # the breakpoints, then configurationDone runs it
253
+ self.entered = True
254
+ self.event("initialized")
255
+ return
256
+ if self.pause_asked and reason == "step":
257
+ reason = "pause"
258
+ self.pause_asked = False
259
+ self.event("stopped", {"reason": reason, "threadId": 1, "allThreadsStopped": True})
260
+
261
+ # --- the variables -----------------------------------------------------------------------
262
+
263
+ def variable(self, name, text, evaluate_name):
264
+ parsed = parse_declare(text)
265
+ if parsed is None:
266
+ return {"name": name, "value": text, "variablesReference": 0}
267
+ flags, value, elements = parsed
268
+ item = {"name": name, "value": value, "evaluateName": evaluate_name,
269
+ "type": "array" if "a" in flags else "associative array" if "A" in flags
270
+ else "integer" if "i" in flags else "string", "variablesReference": 0}
271
+ if elements is not None:
272
+ ref = self.next_ref
273
+ self.next_ref += 1
274
+ assoc = "A" in flags
275
+ self.refs[ref] = [
276
+ {"name": "[%s]" % k, "value": v, "variablesReference": 0,
277
+ "evaluateName": '"${%s[%s]}"' % (name, ('"%s"' % k) if assoc else k)}
278
+ for k, v in elements]
279
+ item["value"] = "(%s)" % " ".join(v for _, v in elements)
280
+ item["variablesReference"] = ref
281
+ item["indexedVariables" if not assoc else "namedVariables"] = len(elements)
282
+ return item
283
+
284
+ # --- the requests ------------------------------------------------------------------------
285
+
286
+ def on_initialize(self, request):
287
+ self.client = request.get("arguments", {})
288
+ self.respond(request, {
289
+ "supportsConfigurationDoneRequest": True,
290
+ "supportsEvaluateForHovers": True,
291
+ "supportsTerminateRequest": True,
292
+ })
293
+
294
+ def on_launch(self, request):
295
+ args = request.get("arguments", {})
296
+ cwd = os.path.realpath(args.get("cwd") or os.getcwd())
297
+ program = args.get("program")
298
+ if not program:
299
+ self.respond(request, success=False, message="no program to debug")
300
+ return
301
+ program = os.path.realpath(os.path.join(cwd, program))
302
+ self.stop_on_entry = bool(args.get("stopOnEntry", False))
303
+ self.dir = tempfile.mkdtemp(prefix="bash-dap-")
304
+ os.mkfifo(os.path.join(self.dir, "commands"))
305
+ os.mkfifo(os.path.join(self.dir, "replies"))
306
+ self.save_breaks()
307
+ env = {"BASH_DAP_DIR": self.dir, "BASH_DAP_HARNESS": HARNESS}
308
+ env.update({k: v for k, v in (args.get("env") or {}).items() if v is not None})
309
+ argv = [args.get("bash") or "bash", "-c", '. "$BASH_DAP_HARNESS"', program]
310
+ argv += [str(a) for a in args.get("args") or []]
311
+ console = args.get("console", "integratedTerminal")
312
+ if console != "internalConsole" and self.client.get("supportsRunInTerminalRequest"):
313
+ # in the terminal of the client: the script reads what is typed there
314
+ def ran(response):
315
+ if not response.get("success"):
316
+ self.respond(request, success=False,
317
+ message=response.get("message", "runInTerminal failed"))
318
+ return
319
+ self.pid = (response.get("body") or {}).get("processId")
320
+ self.respond(request)
321
+
322
+ seq = self.send({"type": "request", "command": "runInTerminal", "arguments": {
323
+ "kind": "external" if console == "externalTerminal" else "integrated",
324
+ "title": "bash-dap", "cwd": cwd, "args": argv, "env": env}})
325
+ self.callbacks[seq] = ran
326
+ else:
327
+ full = dict(os.environ)
328
+ full.update(env)
329
+ try:
330
+ self.proc = subprocess.Popen(argv, cwd=cwd, env=full, stdin=subprocess.DEVNULL,
331
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
332
+ start_new_session=True)
333
+ except OSError as e:
334
+ self.respond(request, success=False, message=str(e))
335
+ return
336
+ for stream, category in ((self.proc.stdout, "stdout"), (self.proc.stderr, "stderr")):
337
+ threading.Thread(target=self.pump, args=(stream, category), daemon=True).start()
338
+ self.respond(request)
339
+ threading.Thread(target=self.harness_loop, daemon=True).start()
340
+
341
+ def pump(self, stream, category):
342
+ for chunk in iter(lambda: stream.read1(4096), b""):
343
+ self.output(chunk.decode(errors="replace"), category)
344
+
345
+ def on_setBreakpoints(self, request):
346
+ args = request.get("arguments", {})
347
+ path = os.path.realpath(args.get("source", {}).get("path", ""))
348
+ lines = [b["line"] for b in args.get("breakpoints", []) if "line" in b]
349
+ self.breaks[path] = lines
350
+ self.save_breaks()
351
+ self.respond(request, {"breakpoints": [
352
+ {"id": i + 1, "verified": True, "line": line, "source": {"path": path}}
353
+ for i, line in enumerate(lines)]})
354
+
355
+ def on_setFunctionBreakpoints(self, request):
356
+ self.respond(request, {"breakpoints": [
357
+ {"verified": False, "message": "bash-dap has breakpoints on lines only"}
358
+ for _ in request.get("arguments", {}).get("breakpoints", [])]})
359
+
360
+ def on_setExceptionBreakpoints(self, request):
361
+ self.respond(request, {"breakpoints": []})
362
+
363
+ def on_configurationDone(self, request):
364
+ self.configured = True
365
+ self.respond(request)
366
+ if self.stop_on_entry:
367
+ self.event("stopped", {"reason": "entry", "threadId": 1, "allThreadsStopped": True})
368
+ else:
369
+ self.resume("run")
370
+
371
+ def on_threads(self, request):
372
+ self.respond(request, {"threads": [{"id": 1, "name": "bash"}]})
373
+
374
+ def on_stackTrace(self, request):
375
+ lines = self.ask("stack") or []
376
+ frames = []
377
+ for kind, rest in lines:
378
+ if kind != "frame":
379
+ continue
380
+ level, func, line, path = (rest.split(" ", 3) + ["", "", "", ""])[:4]
381
+ frames.append({"id": int(level) + 1, "name": func, "line": int(line or 0),
382
+ "column": 1, "source": {"path": path, "name": os.path.basename(path)}})
383
+ args = request.get("arguments", {})
384
+ start = args.get("startFrame", 0)
385
+ levels = args.get("levels") or len(frames)
386
+ self.respond(request, {"stackFrames": frames[start:start + levels],
387
+ "totalFrames": len(frames)})
388
+
389
+ def on_scopes(self, request):
390
+ # bash has dynamic scope: a frame sees the variables of those under it, all at once
391
+ self.respond(request, {"scopes": [
392
+ {"name": "Variables", "presentationHint": "locals",
393
+ "variablesReference": VARIABLES_REF, "expensive": False}]})
394
+
395
+ def on_variables(self, request):
396
+ ref = request.get("arguments", {}).get("variablesReference")
397
+ if ref == VARIABLES_REF:
398
+ items = []
399
+ for kind, rest in self.ask("vars") or []:
400
+ if kind == "var":
401
+ name, _, text = rest.partition(" ")
402
+ items.append(self.variable(name, unquote(text), "$" + name))
403
+ self.respond(request, {"variables": items})
404
+ else:
405
+ self.respond(request, {"variables": self.refs.get(ref, [])})
406
+
407
+ def on_evaluate(self, request):
408
+ args = request.get("arguments", {})
409
+ expression = args.get("expression", "").strip()
410
+ lines = self.ask("print " + expression)
411
+ if lines is None:
412
+ self.respond(request, success=False, message="the script is not stopped")
413
+ return
414
+ values = [unquote(rest) for kind, rest in lines if kind == "value"]
415
+ text = values[0] if values else ""
416
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", expression):
417
+ if text.startswith("bash:") or "not found" in text:
418
+ self.respond(request, success=False, message="%s is not set" % expression)
419
+ return
420
+ item = self.variable(expression, text, expression)
421
+ self.respond(request, {"result": item["value"],
422
+ "variablesReference": item["variablesReference"]})
423
+ return
424
+ self.respond(request, {"result": text, "variablesReference": 0})
425
+
426
+ def on_continue(self, request):
427
+ self.resume("run")
428
+ self.respond(request, {"allThreadsContinued": True})
429
+
430
+ def on_next(self, request):
431
+ self.resume("next")
432
+ self.respond(request)
433
+
434
+ def on_stepIn(self, request):
435
+ self.resume("step")
436
+ self.respond(request)
437
+
438
+ def on_stepOut(self, request):
439
+ self.resume("finish")
440
+ self.respond(request)
441
+
442
+ def on_pause(self, request):
443
+ # every process of the script stops at its next command
444
+ if self.dir is not None and not self.stopped:
445
+ self.pause_asked = True
446
+ with open(os.path.join(self.dir, "state"), "w") as f:
447
+ f.write("step 0\n")
448
+ self.respond(request)
449
+
450
+ def on_disconnect(self, request):
451
+ self.terminate()
452
+ self.respond(request)
453
+ return False
454
+
455
+ def on_terminate(self, request):
456
+ self.terminate()
457
+ self.respond(request)
458
+ self.event("terminated")
459
+
460
+ def terminate(self):
461
+ """The script ended: its group, the session it has to itself. Never by its parent, and
462
+ never the group of the adapter or of the client."""
463
+ group = None
464
+ if self.proc is not None and self.proc.poll() is None:
465
+ group = self.proc.pid # start_new_session: it leads its group
466
+ elif self.pid:
467
+ try:
468
+ group = os.getpgid(self.pid)
469
+ except OSError:
470
+ group = None
471
+ if group and group > 1 and group != os.getpgid(0):
472
+ try:
473
+ os.killpg(group, signal.SIGTERM)
474
+ except OSError:
475
+ pass
476
+ elif self.pid:
477
+ try:
478
+ os.kill(self.pid, signal.SIGTERM)
479
+ except OSError:
480
+ pass
481
+ if self.commands is not None:
482
+ try:
483
+ os.close(self.commands)
484
+ except OSError:
485
+ pass
486
+ self.commands = None
487
+ if self.dir is not None:
488
+ for name in os.listdir(self.dir):
489
+ try:
490
+ os.unlink(os.path.join(self.dir, name))
491
+ except OSError:
492
+ pass
493
+ try:
494
+ os.rmdir(self.dir)
495
+ except OSError:
496
+ pass
497
+ self.dir = None
498
+
499
+ def run(self):
500
+ while True:
501
+ message = self.read_message()
502
+ if message is None:
503
+ break
504
+ if message.get("type") == "response":
505
+ callback = self.callbacks.pop(message.get("request_seq"), None)
506
+ if callback is not None:
507
+ callback(message)
508
+ continue
509
+ if message.get("type") != "request":
510
+ continue
511
+ handler = getattr(self, "on_" + message.get("command", ""), None)
512
+ if handler is None:
513
+ self.respond(message, success=False,
514
+ message="bash-dap: %s is not supported" % message.get("command"))
515
+ continue
516
+ try:
517
+ if handler(message) is False:
518
+ break
519
+ except Exception as e: # a request that fails is told, the session goes on
520
+ self.respond(message, success=False, message="bash-dap: %s" % e)
521
+ self.terminate()
522
+
523
+
524
+ def main():
525
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
526
+ print("bash-dap: a debug adapter for bash scripts; it speaks the Debug Adapter Protocol "
527
+ "on stdin and stdout")
528
+ return 0
529
+ if len(sys.argv) > 1 and sys.argv[1] == "--version":
530
+ print("bash-dap 0.1")
531
+ return 0
532
+ Adapter(sys.stdin.buffer, sys.stdout.buffer).run()
533
+ return 0
534
+
535
+
536
+ if __name__ == "__main__":
537
+ sys.exit(main())