[Lldb-commits] [lldb] [lldb] Add `pipe` command for piping through shell commands (PR #201751)

via lldb-commits lldb-commits at lists.llvm.org
Thu Jun 4 22:37:31 PDT 2026


llvmorg-github-actions[bot] wrote:


<!--LLVM PR SUMMARY COMMENT-->

@llvm/pr-subscribers-lldb

Author: Dave Lee (kastiglione)

<details>
<summary>Changes</summary>

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.


---
Full diff: https://github.com/llvm/llvm-project/pull/201751.diff


3 Files Affected:

- (modified) lldb/bindings/python/CMakeLists.txt (+1) 
- (added) lldb/examples/python/pipe.py (+144) 
- (added) lldb/test/API/commands/command/pipe/TestPipeCommand.py (+65) 


``````````diff
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)

``````````

</details>


https://github.com/llvm/llvm-project/pull/201751


More information about the lldb-commits mailing list