Skip to content

Redirect after a list or pipeline attaches to the whole list instead of the last command #345

Open

Description

@marcospgp

In bash a redirect at the end of a list or pipeline applies only to the last command. The grammar attaches it to the whole list or pipeline.

cd build && make 2>/dev/null

parses as

(program (redirected_statement body: (list (command name: (command_name (word)) argument: (word)) (command name: (command_name (word)))) redirect: (file_redirect descriptor: (file_descriptor) destination: (word))))

Expected, since only make's stderr is redirected:

(program (list (command name: (command_name (word)) argument: (word)) (redirected_statement body: (command name: (command_name (word))) redirect: (file_redirect descriptor: (file_descriptor) destination: (word)))))

A pipeline does the same: a | b > f parses as a redirected_statement whose body is the whole pipeline.

Bash shows the redirect belongs to the last command. The first command's error still prints in both of these:

ls /nonexistent || true 2>/dev/null
ls /nonexistent | cat 2>/dev/null

A heredoc behaves the same way: in cd dir && python3 - <<'PY' the heredoc_redirect hangs on the list, not on python3.

This matters to tools that read the tree to learn what a command writes or silences. They would read git fetch && ls 2>/dev/null as silencing git's errors.

Seen on 0.25.1 through web-tree-sitter 0.27.0. The closest issue we found is #167, "pipeline nodes shouldn't nest", which is closed.

Activity

  1. fprochazka commented on Sep 19, 2026

    @fprochazka

    A pipe after the redirect makes this worse than misattribution — it changes pipeline membership.

    foo && cat f > out | head -80
    
    (program
      (pipeline
        (redirected_statement
          body: (list
            (command name: (command_name (word)))
            (command name: (command_name (word)) argument: (word)))
          redirect: (file_redirect destination: (word)))
        (command name: (command_name (word)) argument: (number))))
    

    The over-extended redirected_statement is now the first member of the pipeline, so the tree says (foo && cat f > out) | head -80. Bash binds the pipe tighter than && and runs foo && (cat f > out | head -80):

    $ false && echo hi | wc -l
    $ echo "exit=$?"
    exit=1
    

    Nothing is printed. Grouped the other way, wc -l would run and print 0.

    Without the redirect the same shape nests correctly:

    foo && cat f | head -80
    
    (program
      (list
        (command name: (command_name (word)))
        (pipeline
          (command name: (command_name (word)) argument: (word))
          (command name: (command_name (word)) argument: (number)))))
    

    And with the redirect on an earlier member rather than the last one it is also correct:

    foo > o && cat f | head -80
    
    (program
      (list
        (redirected_statement body: (command ...) redirect: (file_redirect ...))
        (pipeline (command ...) (command ...))))
    

    So the inversion needs three things together: an and-or list, a redirect on its last member, and a pipe after it. 2> behaves identically to > here, which matches bash — a redirect has no effect on pipeline shape.

    Seen on tree-sitter-bash 0.25.1 with the Python binding 0.25.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions