[Lldb-commits] [lldb] [lldb] Add `pipe` command for piping through shell commands (PR #201751)
Dave Lee via lldb-commits
lldb-commits at lists.llvm.org
Fri Jun 5 11:53:35 PDT 2026
https://github.com/kastiglione updated https://github.com/llvm/llvm-project/pull/201751
>From f7a176229ea9a219a67aa455736faa551f9ba718 Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Thu, 4 Jun 2026 22:24:46 -0700
Subject: [PATCH 1/3] [lldb] Add `pipe` command for piping through shell
commands
Add a new Python utility (`lldb.utils.pipe`) that lets users pipe the output of lldb
commands through shell pipelines or redirect to files.
The command distinguishs lldb commands from shell commands, both work transparently:
(lldb) pipe bt all | grep main
(lldb) pipe settings list > /tmp/settings.txt
(lldb) pipe /bin/ls | head -5
Pager detection (`less`, `more`, `bat`, `$PAGER`) is used to bypass output capture,
ensuring that interactive pagers can access the tty.
---
lldb/bindings/python/CMakeLists.txt | 1 +
lldb/examples/python/pipe.py | 144 ++++++++++++++++++
.../commands/command/pipe/TestPipeCommand.py | 65 ++++++++
3 files changed, 210 insertions(+)
create mode 100644 lldb/examples/python/pipe.py
create mode 100644 lldb/test/API/commands/command/pipe/TestPipeCommand.py
diff --git a/lldb/bindings/python/CMakeLists.txt b/lldb/bindings/python/CMakeLists.txt
index e4f978ed80bc3..dacfd49054ada 100644
--- a/lldb/bindings/python/CMakeLists.txt
+++ b/lldb/bindings/python/CMakeLists.txt
@@ -102,6 +102,7 @@ function(finish_swig_python swig_target lldb_python_bindings_dir lldb_python_tar
${lldb_python_target_dir}
"utils"
FILES "${LLDB_SOURCE_DIR}/examples/python/in_call_stack.py"
+ "${LLDB_SOURCE_DIR}/examples/python/pipe.py"
"${LLDB_SOURCE_DIR}/examples/python/symbolication.py"
)
diff --git a/lldb/examples/python/pipe.py b/lldb/examples/python/pipe.py
new file mode 100644
index 0000000000000..183df8d11ff5a
--- /dev/null
+++ b/lldb/examples/python/pipe.py
@@ -0,0 +1,144 @@
+r"""
+Pipe or redirect LLDB command output through shell commands.
+
+Usage:
+ (lldb) pipe <lldb-command> | <shell-pipeline>
+ (lldb) pipe <lldb-command> > <file>
+Examples:
+ (lldb) pipe image list | wc -l
+ (lldb) pipe settings list | grep color
+ (lldb) pipe bt all | grep Foundation
+ (lldb) pipe bt all > /tmp/stack.txt
+ (lldb) pipe bt all | tee /tmp/stack.txt
+ (lldb) pipe frame variable *this | pbcopy
+ (lldb) pipe breakpoint list | grep 'where = .*resolved'
+ (lldb) pipe register read | sort
+
+Import:
+ (lldb) command script import lldb.utils.pipe
+"""
+
+import os
+import shlex
+import subprocess
+from typing import Iterable, Iterator, Optional, Tuple
+
+import lldb
+
+
+# Equivalent to itertools.pairwise, available in Python 3.10+.
+def _pairwise(iterable: Iterable) -> Iterator[Tuple]:
+ """Yield consecutive overlapping pairs: _pairwise(ABCD) -> AB BC CD."""
+ iterator = iter(iterable)
+ a = next(iterator, None)
+ for b in iterator:
+ yield a, b
+ a = b
+
+
+def _tokenize(cmdstr: str) -> shlex.shlex:
+ """Splits on whitespace and treats | and > as standalone punctuation, even
+ without surrounding spaces."""
+
+ lex = shlex.shlex(cmdstr, posix=True, punctuation_chars="|>")
+ lex.whitespace_split = True
+ return lex
+
+
+def _split_cmd(cmdstr: str) -> Tuple[str, Optional[str]]:
+ """Split cmdstr at the first unquoted shell operator (| or >)."""
+ lex = _tokenize(cmdstr)
+ for token in lex:
+ if token in ("|", ">"):
+ # Search up to where shlex has consumed.
+ split = cmdstr.rindex(token, 0, lex.instream.tell())
+ first_cmd = cmdstr[:split].rstrip()
+ shell_suffix = cmdstr[split:]
+ return first_cmd, shell_suffix
+ return cmdstr, None
+
+
+def _pager_names() -> set[str]:
+ """Return known pager command names, including $PAGER if set."""
+ pagers = {"less", "more", "most", "bat"}
+ if pager := os.environ.get("PAGER"):
+ # shlex.split handles flags, e.g. "less -R" -> "less".
+ pager = shlex.split(pager)[0]
+ pagers.add(os.path.basename(pager))
+ return pagers
+
+
+_PAGERS = _pager_names()
+
+
+def _ends_with_pager(shell_cmd: str) -> bool:
+ """Check if the last command in a shell pipeline is a pager."""
+ lex = _tokenize(shell_cmd)
+ for tok1, tok2 in reversed(list(_pairwise(lex))):
+ if tok1 == "|":
+ return os.path.basename(tok2) in _PAGERS
+ return False
+
+
+def _is_lldb_command(interp: lldb.SBCommandInterpreter, cmdstr: str) -> bool:
+ result = lldb.SBCommandReturnObject()
+ interp.ResolveCommand(cmdstr, result)
+ return result.Succeeded()
+
+
+ at lldb.command()
+def pipe(
+ debugger: lldb.SBDebugger,
+ cmdstr: str,
+ exe_ctx: lldb.SBExecutionContext,
+ result: lldb.SBCommandReturnObject,
+ _,
+):
+ """Pipe or redirect LLDB command output through shell commands."""
+ interp = debugger.GetCommandInterpreter()
+
+ first_cmd, shell_suffix = _split_cmd(cmdstr)
+
+ if not first_cmd and shell_suffix:
+ result.SetError("no command before shell operator")
+ return
+
+ if _is_lldb_command(interp, first_cmd):
+ if shell_suffix is None:
+ # Run a single lldb command, shell command to pipe to.
+ interp.HandleCommand(first_cmd, exe_ctx, result)
+ return
+
+ cmd_result = lldb.SBCommandReturnObject()
+ interp.HandleCommand(first_cmd, exe_ctx, cmd_result)
+
+ if cmd_result.GetError():
+ result.SetError(cmd_result.GetError())
+ if not cmd_result.Succeeded():
+ if not cmd_result.GetError():
+ result.SetError(f"command failed: {first_cmd}")
+ return
+
+ output = cmd_result.GetOutput()
+ # `cat` works with both | and > operators.
+ shell_cmd = f"cat {shell_suffix}"
+ else:
+ # No lldb command to run, only shell.
+ output = None
+ shell_cmd = cmdstr
+
+ try:
+ if _ends_with_pager(shell_cmd):
+ proc = subprocess.Popen(shell_cmd, shell=True, stdin=subprocess.PIPE)
+ proc.communicate(output.encode() if output else None)
+ else:
+ proc = subprocess.run(
+ shell_cmd, shell=True, input=output, capture_output=True, text=True
+ )
+ if proc.stdout:
+ result.PutCString(proc.stdout)
+ if proc.stderr:
+ result.SetError(proc.stderr)
+ except OSError as e:
+ result.SetError(f"failed to run shell command: {e}")
+ return
diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
new file mode 100644
index 0000000000000..dffdee4414700
--- /dev/null
+++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
@@ -0,0 +1,65 @@
+"""
+Test the pipe command (lldb.utils.pipe).
+"""
+
+import os
+import tempfile
+
+import lldb
+from lldbsuite.test.lldbtest import *
+
+
+class TestCase(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def setUp(self):
+ TestBase.setUp(self)
+ self.runCmd("command script import lldb.utils.pipe")
+
+ def test_pipe_to_grep(self):
+ """Test piping an LLDB command through grep."""
+ self.expect("pipe help help | grep Syntax", substrs=["Syntax"])
+
+ def test_lldb_command_no_pipe(self):
+ """Test running a plain LLDB command (passthrough)."""
+ self.expect("pipe help help", substrs=["Syntax"])
+
+ def test_pipe_chain(self):
+ """Test piping through multiple shell commands."""
+ self.expect("pipe help help | tr s-z S-Z | grep -i syntax", substrs=["SYnTaX"])
+
+ def test_redirect_stdout(self):
+ """Test redirecting LLDB command output to a file."""
+ with tempfile.NamedTemporaryFile(mode="r", delete=False) as f:
+ path = f.name
+ try:
+ self.runCmd(f"pipe help help > {path}")
+ with open(path) as f:
+ contents = f.read()
+ self.assertIn("Syntax", contents)
+ finally:
+ os.unlink(path)
+
+ def test_shell_command_no_pipe(self):
+ """Test running a plain shell command (not an LLDB command)."""
+ self.expect("pipe echo hello", substrs=["hello"])
+
+ def test_shell_command_with_pipe(self):
+ """Test running a shell command piped to another shell command."""
+ self.expect("pipe echo hello world | tr a-z A-Z", substrs=["HELLO WORLD"])
+
+ def test_quoted_pipe_not_split(self):
+ """Test that a pipe character inside quotes is not treated as a split."""
+ self.expect("pipe echo 'abc|def' | tr a-c A-C", substrs=["ABC|def"])
+
+ def test_pipe_without_spaces(self):
+ """Test that pipe works without spaces around the | operator."""
+ self.expect("pipe help help|grep Syntax", substrs=["Syntax"])
+
+ def test_error_no_command_before_pipe(self):
+ """Test error when nothing precedes the pipe operator."""
+ self.expect("pipe | grep foo", error=True)
+
+ def test_failed_lldb_command(self):
+ """Test that a non-lldb non-shell command reports an error."""
+ self.expect("pipe not_a_real_lldb_command_xyz | wc -l", error=True)
>From b99ac609668725367ac7819ee88c39612b378a55 Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Fri, 5 Jun 2026 10:25:53 -0700
Subject: [PATCH 2/3] Add >> support; Disable some tests for windows
---
lldb/examples/python/pipe.py | 23 ++++++++++---------
.../commands/command/pipe/TestPipeCommand.py | 21 ++++++++++++++++-
2 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/lldb/examples/python/pipe.py b/lldb/examples/python/pipe.py
index 183df8d11ff5a..61f89485767dc 100644
--- a/lldb/examples/python/pipe.py
+++ b/lldb/examples/python/pipe.py
@@ -4,6 +4,7 @@
Usage:
(lldb) pipe <lldb-command> | <shell-pipeline>
(lldb) pipe <lldb-command> > <file>
+ (lldb) pipe <lldb-command> >> <file>
Examples:
(lldb) pipe image list | wc -l
(lldb) pipe settings list | grep color
@@ -46,12 +47,12 @@ def _tokenize(cmdstr: str) -> shlex.shlex:
def _split_cmd(cmdstr: str) -> Tuple[str, Optional[str]]:
- """Split cmdstr at the first unquoted shell operator (| or >)."""
- lex = _tokenize(cmdstr)
- for token in lex:
- if token in ("|", ">"):
+ """Split cmdstr at the first unquoted shell operator (|, >, or >>)."""
+ tokens = _tokenize(cmdstr)
+ for token in tokens:
+ if token in ("|", ">", ">>"):
# Search up to where shlex has consumed.
- split = cmdstr.rindex(token, 0, lex.instream.tell())
+ split = cmdstr.rindex(token, 0, tokens.instream.tell())
first_cmd = cmdstr[:split].rstrip()
shell_suffix = cmdstr[split:]
return first_cmd, shell_suffix
@@ -73,10 +74,10 @@ def _pager_names() -> set[str]:
def _ends_with_pager(shell_cmd: str) -> bool:
"""Check if the last command in a shell pipeline is a pager."""
- lex = _tokenize(shell_cmd)
- for tok1, tok2 in reversed(list(_pairwise(lex))):
- if tok1 == "|":
- return os.path.basename(tok2) in _PAGERS
+ tokens = _tokenize(shell_cmd)
+ for token1, token2 in reversed(list(_pairwise(tokens))):
+ if token1 == "|":
+ return os.path.basename(token2) in _PAGERS
return False
@@ -120,7 +121,8 @@ def pipe(
return
output = cmd_result.GetOutput()
- # `cat` works with both | and > operators.
+ # Prefixing shell_suffix with `cat` is a single way to make both `| ...`
+ # and `> ...` work as a subprocess command.
shell_cmd = f"cat {shell_suffix}"
else:
# No lldb command to run, only shell.
@@ -141,4 +143,3 @@ def pipe(
result.SetError(proc.stderr)
except OSError as e:
result.SetError(f"failed to run shell command: {e}")
- return
diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
index dffdee4414700..e4cb4a1cc1fff 100644
--- a/lldb/test/API/commands/command/pipe/TestPipeCommand.py
+++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
@@ -7,7 +7,7 @@
import lldb
from lldbsuite.test.lldbtest import *
-
+from lldbsuite.test.decorators import *
class TestCase(TestBase):
NO_DEBUG_INFO_TESTCASE = True
@@ -16,6 +16,7 @@ def setUp(self):
TestBase.setUp(self)
self.runCmd("command script import lldb.utils.pipe")
+ @skipIfWindows
def test_pipe_to_grep(self):
"""Test piping an LLDB command through grep."""
self.expect("pipe help help | grep Syntax", substrs=["Syntax"])
@@ -24,6 +25,7 @@ def test_lldb_command_no_pipe(self):
"""Test running a plain LLDB command (passthrough)."""
self.expect("pipe help help", substrs=["Syntax"])
+ @skipIfWindows
def test_pipe_chain(self):
"""Test piping through multiple shell commands."""
self.expect("pipe help help | tr s-z S-Z | grep -i syntax", substrs=["SYnTaX"])
@@ -40,18 +42,35 @@ def test_redirect_stdout(self):
finally:
os.unlink(path)
+ def test_append_stdout(self):
+ """Test appending LLDB command output to a file."""
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write("first\n")
+ path = f.name
+ try:
+ self.runCmd(f"pipe help help >> {path}")
+ with open(path) as f:
+ contents = f.read()
+ self.assertTrue(contents.startswith("first\n"))
+ self.assertIn("Syntax", contents)
+ finally:
+ os.unlink(path)
+
def test_shell_command_no_pipe(self):
"""Test running a plain shell command (not an LLDB command)."""
self.expect("pipe echo hello", substrs=["hello"])
+ @skipIfWindows
def test_shell_command_with_pipe(self):
"""Test running a shell command piped to another shell command."""
self.expect("pipe echo hello world | tr a-z A-Z", substrs=["HELLO WORLD"])
+ @skipIfWindows
def test_quoted_pipe_not_split(self):
"""Test that a pipe character inside quotes is not treated as a split."""
self.expect("pipe echo 'abc|def' | tr a-c A-C", substrs=["ABC|def"])
+ @skipIfWindows
def test_pipe_without_spaces(self):
"""Test that pipe works without spaces around the | operator."""
self.expect("pipe help help|grep Syntax", substrs=["Syntax"])
>From fcb5cc58a0e4534a9bf8c63d97ead74755113679 Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Fri, 5 Jun 2026 11:53:17 -0700
Subject: [PATCH 3/3] reformat
---
lldb/test/API/commands/command/pipe/TestPipeCommand.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
index e4cb4a1cc1fff..0ff6e6649ec05 100644
--- a/lldb/test/API/commands/command/pipe/TestPipeCommand.py
+++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py
@@ -9,6 +9,7 @@
from lldbsuite.test.lldbtest import *
from lldbsuite.test.decorators import *
+
class TestCase(TestBase):
NO_DEBUG_INFO_TESTCASE = True
More information about the lldb-commits
mailing list