How does bash differentiate between brace expansion and command grouping?

A simplified reason is the existence of one character: space.

Brace expansions do not process (un-quoted) spaces.

A {...} list needs (un-quoted) spaces.

The more detailed answer is how the shell parses a command line.


The first step to parse (understand) a command line is to divide it into parts.
These parts (usually called words or tokens) result from dividing a command line at each meta-character from the link:

  1. Splits the command into tokens that are separated by the fixed set of meta-characters: SPACE, TAB, NEWLINE, ;, (, ), <, >, |, and &. Types of tokens include words, keywords, I/O redirectors, and semicolons.

Meta-characters: spacetabenter;,<>| and &.

After splitting, words may be of a type (as understood by the shell):

  • Command pre-asignements: LC=ALL ...
  • Command LC=ALL echo
  • Arguments LC=ALL echo "hello"
  • Redirection LC=ALL echo "hello" >&2

Brace expansion

Only if a "brace string" (without spaces or meta-characters) is a single word (as described above) and is not quoted, it is a candidate for "Brace expansion". More checks are performed on the internal structure later.

Thus, this: {ls,-l} qualifies as "Brace expansion" to become ls -l, either as first word or argument (in bash, zsh is different).

$ {ls,-l}            ### executes `ls -l`
$ echo {ls,-l}       ### prints `ls -l`

But this will not: {ls ,-l}. Bash will split on space and parse the line as two words: {ls and ,-l} which will trigger a command not found (the argument ,-l} is lost):

 $ {ls ,-l}
 bash: {ls: command not found

Your line: {ls;echo hi} will not become a "Brace expansion" because of the two meta-characters ; and space.

It will be broken into this three parts: {ls new command: echo hi}. Understand that the ; triggers the start of a new command. The command {ls will not be found, and the next command will print hi}:

$ {ls;echo hi}
bash: {ls: command not found
hi}

If it is placed after some other command, it will anyway start a new command after the ;:

$ echo {ls;echo hi}
{ls
hi}

List

One of the "compound commands" is a "Brace List" (my words): { list; }.
As you can see, it is defined with spaces and a closing ;.
The spaces and ; are needed because both { and } are "Reserved Words".

And therefore, to be recognized as words, must be surrounded by meta-characters (almost always: space).

As described in the point 2 of the linked page

  1. Checks the first token of each command to see if it is .... , {, or (, then the command is actually a compound command.

Your example: {ls;echo hi} is not a list.

It needs a closing ; and one space (at least) after {. The last } is defined by the closing ;.

This is a list { ls;echo hi; }. And this { ls;echo hi;} is also (less commonly used, but valid)(Thanks @choroba for the help).

$ { ls;echo hi; }
A-list-of-files
hi

But as argument (the shell knows the difference) to a command, it triggers an error:

$ echo { ls;echo hi; }
bash: syntax error near unexpected token `}'

But be careful in what you believe the shell is parsing:

$ echo { ls;echo hi;
{ ls
hi

The block { is a shell keyword, so it must separated from the next word by space, while in brace expansion, there should be no space (if you need to brace expand a space, you have to escape it: echo {\ ,a}{b,c}).

You can use brace expansion at the start of a command:

{ls,.}  # expands to "ls ."

You can't use it to expand to a block, though, as parsing of the grouping commands happens before expansions:

echo {'{ ls','.;}'}  # { ls .;}
{'{ ls','.;}'}       # bash: { ls: No such file or directory

It knows by checking the syntax of the command line. In the same way it knows that in the expression echo echo, the first echo should be treated as a command and the second echo as a parameter of the first echo.

In bash it is very simple, since { cmd; } should have spaces and semicolon. However, for example in zsh they are not needed, but still by analyzing context of {} shell is able to tell what should be done with its content.

Consider the following:

alias 1..3=date
{ 1..3; }    #in bash
{1..3}       #in zsh

Both return current date, but

echo {1..3}

returns 1 2 3 because shell knows {} in an argument for command echo, so should be expanded.