fixed bash-for-windows

This commit is contained in:
Cametendo
2026-05-31 21:49:13 +02:00
parent 7b3a101946
commit 9037606447
8 changed files with 437 additions and 123 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ release/
*.exe *.exe
*.o *.o
tmp/ tmp/
waifufetch/

View File

@@ -68,6 +68,42 @@ bash script.sh # run a script file
bash script.sh arg1 # pass arguments ($1, $2, ...) bash script.sh arg1 # pass arguments ($1, $2, ...)
``` ```
### Running bash scripts
Any file with a `#!/usr/bin/env bash` or `#!/bin/bash` shebang is automatically detected and executed through bash-for-windows — no need to invoke `bash` explicitly.
**Run by passing the path directly:**
```powershell
bash myscript.sh
bash myscript # extension is optional
bash C:\scripts\deploy.sh production
```
**Or put the script on PATH and call it by name:**
If the script is in a directory that is on your `PATH` (e.g. the bash-for-windows install directory), you can call it directly from the interactive shell or from PowerShell:
```
waifufetch
waifu
deploy
```
Bash-for-windows detects the shebang, runs the script through its own interpreter, and passes any arguments as `$1`, `$2`, etc.
**CRLF line endings are handled automatically.** Scripts checked out on Windows often have `\r\n` line endings. Bash-for-windows strips the carriage returns before executing, so `#!/usr/bin/env bash\r` in the shebang line never causes the `env: 'bash\r': No such file or directory` error you get with WSL.
**Adding a script to PATH:**
The easiest place to drop scripts is the same directory bash-for-windows is installed in:
```powershell
$d = "$env:LOCALAPPDATA\Programs\BashForWindows"
Copy-Item .\myscript $d\myscript
```
That directory is already on `PATH` after running `install.ps1`, so the script is immediately callable from any shell.
### Examples ### Examples
```bash ```bash

View File

@@ -43,7 +43,7 @@ func runFile(path string, args []string) error {
return fmt.Errorf("%s: %v", path, err) return fmt.Errorf("%s: %v", path, err)
} }
sh := shell.New() sh := shell.New()
sh.SetArgs(append([]string{path}, args...)) sh.SetArgs(args)
sh.SetVar("0", path) sh.SetVar("0", path)
return sh.Execute(string(data)) return sh.Execute(string(data))
} }

View File

@@ -18,6 +18,8 @@ var aliases = make(map[string]string)
func (s *Shell) initBuiltins() { func (s *Shell) initBuiltins() {
s.builtins = map[string]func([]string) error{ s.builtins = map[string]func([]string) error{
"{": s.builtinTrue,
"}": s.builtinTrue,
// Shell builtins // Shell builtins
"cd": s.builtinCd, "cd": s.builtinCd,
"pwd": s.builtinPwd, "pwd": s.builtinPwd,
@@ -442,12 +444,18 @@ func (s *Shell) builtinPrintf(args []string) error {
fmtArgs := args[1:] fmtArgs := args[1:]
var result strings.Builder var result strings.Builder
// POSIX: if more arguments than format specifiers, re-apply the format
// until all args are consumed. Track consumed count per pass to detect
// a format with no specifiers (avoid infinite loop).
argIdx := 0 argIdx := 0
for {
consumed := 0
i := 0 i := 0
for i < len(format) { for i < len(format) {
if format[i] == '%' && i+1 < len(format) { if format[i] == '%' && i+1 < len(format) {
i++ i++
// Optional width/precision // Optional width/precision flags
specStart := i specStart := i
for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') { for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') {
i++ i++
@@ -460,6 +468,7 @@ func (s *Shell) builtinPrintf(args []string) error {
if argIdx < len(fmtArgs) { if argIdx < len(fmtArgs) {
arg = fmtArgs[argIdx] arg = fmtArgs[argIdx]
argIdx++ argIdx++
consumed++
} }
switch format[i] { switch format[i] {
case 's': case 's':
@@ -490,11 +499,15 @@ func (s *Shell) builtinPrintf(args []string) error {
result.WriteString(fmt.Sprintf("%o", n)) result.WriteString(fmt.Sprintf("%o", n))
case '%': case '%':
result.WriteByte('%') result.WriteByte('%')
argIdx-- // no arg consumed if consumed > 0 {
consumed-- // %% consumes no arg
}
default: default:
result.WriteByte('%') result.WriteByte('%')
result.WriteByte(format[i]) result.WriteByte(format[i])
argIdx-- if consumed > 0 {
consumed--
}
} }
i++ i++
} else if format[i] == '\\' && i+1 < len(format) { } else if format[i] == '\\' && i+1 < len(format) {
@@ -512,6 +525,16 @@ func (s *Shell) builtinPrintf(args []string) error {
result.WriteByte('\a') result.WriteByte('\a')
case 'b': case 'b':
result.WriteByte('\b') result.WriteByte('\b')
case 'e', 'E':
result.WriteByte(0x1b) // ESC
case '0', '1', '2', '3', '4', '5', '6', '7':
// Octal escape \NNN (up to 3 octal digits)
oct := int(format[i] - '0')
for k := 0; k < 2 && i+1 < len(format) && format[i+1] >= '0' && format[i+1] <= '7'; k++ {
i++
oct = oct*8 + int(format[i]-'0')
}
result.WriteByte(byte(oct))
default: default:
result.WriteByte('\\') result.WriteByte('\\')
result.WriteByte(format[i]) result.WriteByte(format[i])
@@ -522,6 +545,12 @@ func (s *Shell) builtinPrintf(args []string) error {
i++ i++
} }
} }
// Stop looping if all args consumed, no args given, or format has
// no specifiers (consumed==0 means infinite loop risk).
if argIdx >= len(fmtArgs) || consumed == 0 {
break
}
}
fmt.Fprint(s.Stdout, result.String()) fmt.Fprint(s.Stdout, result.String())
return nil return nil
} }

View File

@@ -21,17 +21,40 @@ func (s *Shell) executeIf(block string) error {
phase := "if_cond" phase := "if_cond"
var curCond []string var curCond []string
var curBody []string var curBody []string
depth := 0 // nesting depth of if/for/while/until/case inside the body
addToBody := func(s string) {
switch phase {
case "body":
curBody = append(curBody, s)
case "else":
elseBody = append(elseBody, s)
}
}
for _, stmt := range stmts { for _, stmt := range stmts {
w := firstWord(stmt) w := firstWord(stmt)
rest := afterWord(stmt) rest := afterWord(stmt)
// When depth > 0, we're inside a nested block; all keywords are body content.
if depth > 0 {
switch w {
case "if", "for", "while", "until", "case":
depth++
case "fi", "done", "esac":
depth--
}
addToBody(stmt)
continue
}
// depth == 0: structural keywords for the outer if
switch { switch {
case w == "if" && phase == "if_cond": case w == "if" && phase == "if_cond":
if rest != "" { if rest != "" {
curCond = append(curCond, rest) curCond = append(curCond, rest)
} }
case w == "then": case w == "then" && (phase == "if_cond" || phase == "elif_cond"):
if rest != "" { if rest != "" {
curBody = append(curBody, rest) curBody = append(curBody, rest)
} }
@@ -67,8 +90,17 @@ func (s *Shell) executeIf(block string) error {
curCond = append(curCond, stmt) curCond = append(curCond, stmt)
case "body": case "body":
curBody = append(curBody, stmt) curBody = append(curBody, stmt)
// Track depth for nested blocks starting in body
switch w {
case "if", "for", "while", "until", "case":
depth++
}
case "else": case "else":
elseBody = append(elseBody, stmt) elseBody = append(elseBody, stmt)
switch w {
case "if", "for", "while", "until", "case":
depth++
}
} }
} }
} }

View File

@@ -58,6 +58,12 @@ func splitPipe(input string) []string {
case c == '"' && !inSingle && parenDepth == 0: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
// Process substitution <(...): don't split | inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
case c == '$' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(': case c == '$' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
pendingDollar = true pendingDollar = true
current.WriteByte(c) current.WriteByte(c)
@@ -154,6 +160,15 @@ func (s *Shell) executeCommand(input string) error {
return nil return nil
} }
// { cmd; cmd; } command group
if strings.HasPrefix(input, "{") {
end := strings.LastIndex(input, "}")
if end > 0 {
inner := strings.TrimSpace(input[1:end])
return s.Execute(inner)
}
}
// Detect array assignment NAME=(...) or NAME+=(...) // Detect array assignment NAME=(...) or NAME+=(...)
if name, appendMode, elems, ok := s.parseArrayAssign(input); ok { if name, appendMode, elems, ok := s.parseArrayAssign(input); ok {
if appendMode { if appendMode {
@@ -264,6 +279,20 @@ func (s *Shell) extractRedirects(tokens []string) ([]string, []redirect) {
case strings.HasPrefix(tok, ">") && len(tok) > 1: case strings.HasPrefix(tok, ">") && len(tok) > 1:
redirects = append(redirects, redirect{1, ">", tok[1:]}) redirects = append(redirects, redirect{1, ">", tok[1:]})
i++ i++
// <<< here-string
case tok == "<<<":
if i+1 < len(tokens) {
val := tokens[i+1] + "\n"
tmpf, err := os.CreateTemp("", "herestr*")
if err == nil {
tmpf.WriteString(val)
tmpf.Close()
redirects = append(redirects, redirect{0, "<", tmpf.Name()})
}
i += 2
} else {
i++
}
// < (stdin) — also handle process substitution <(...) // < (stdin) — also handle process substitution <(...)
case tok == "<": case tok == "<":
if i+1 < len(tokens) { if i+1 < len(tokens) {
@@ -311,7 +340,11 @@ func (s *Shell) withRedirects(redirects []redirect, fn func() error) error {
} }
}() }()
for _, r := range redirects { for i, r := range redirects {
if r.dest == "/dev/null" {
redirects[i].dest = os.DevNull
r = redirects[i]
}
switch r.mode { switch r.mode {
case ">": case ">":
if r.dest == "&1" { if r.dest == "&1" {
@@ -361,6 +394,20 @@ func (s *Shell) executeExternal(cmdName string, args []string) error {
fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName) fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName)
return exitCodeErr{127} return exitCodeErr{127}
} }
// If the file is a bash/sh script, run it through our own interpreter.
// This avoids the CRLF shebang problem (#!/usr/bin/env bash\r) and lets
// us execute scripts that have no .exe extension on Windows.
if data, err := os.ReadFile(path); err == nil && isShellScript(data) {
sh := New()
sh.Stdin = s.Stdin
sh.Stdout = s.Stdout
sh.Stderr = s.Stderr
sh.SetArgs(args)
sh.SetVar("0", path)
return sh.Execute(string(data))
}
cmd := exec.Command(path, args...) cmd := exec.Command(path, args...)
cmd.Stdin = s.Stdin cmd.Stdin = s.Stdin
cmd.Stdout = s.Stdout cmd.Stdout = s.Stdout
@@ -374,6 +421,25 @@ func (s *Shell) executeExternal(cmdName string, args []string) error {
return err return err
} }
// isShellScript returns true when data begins with a #!/…bash or #!/…sh shebang.
func isShellScript(data []byte) bool {
if len(data) < 2 || data[0] != '#' || data[1] != '!' {
return false
}
nl := bytes.IndexByte(data, '\n')
if nl < 0 {
nl = len(data)
}
line := strings.TrimRight(string(data[:nl]), "\r")
for _, word := range strings.Fields(line[2:]) {
base := filepath.Base(word)
if base == "bash" || base == "sh" {
return true
}
}
return false
}
func (s *Shell) executeCommandBg(input string) error { func (s *Shell) executeCommandBg(input string) error {
tokens := s.tokenize(input) tokens := s.tokenize(input)
if len(tokens) == 0 { if len(tokens) == 0 {

View File

@@ -82,7 +82,6 @@ func (s *Shell) expandWord(word string) string {
result.WriteByte('$') result.WriteByte('$')
break break
} }
// $'...' ANSI C string // $'...' ANSI C string
if word[i] == '\'' { if word[i] == '\'' {
i++ // skip opening ' i++ // skip opening '
@@ -232,7 +231,11 @@ func (s *Shell) expandWord(word string) string {
result.WriteString(s.vars["#"]) result.WriteString(s.vars["#"])
i++ i++
case '@': case '@':
if len(s.args) == 0 {
result.WriteString("\x01") // empty-expansion sentinel → 0 tokens
} else {
result.WriteString(strings.Join(s.args, "\x01")) result.WriteString(strings.Join(s.args, "\x01"))
}
i++ i++
case '*': case '*':
result.WriteString(s.vars["*"]) result.WriteString(s.vars["*"])
@@ -278,29 +281,95 @@ func (s *Shell) evalVarExpr(expr string) string {
return strconv.Itoa(len(s.getVar(rest))) return strconv.Itoa(len(s.getVar(rest)))
} }
// Array indexing: ${arr[@]}, ${arr[*]}, ${arr[N]} // Array indexing: ${arr[@]}, ${arr[*]}, ${arr[N]}, ${arr[N]:-default}, etc.
if bracketIdx := strings.Index(expr, "["); bracketIdx >= 0 && strings.HasSuffix(expr, "]") { // Find the bracket pair and evaluate the array access even when an operator
// Make sure there's no operator before the bracket // (:- := :+) follows the closing ].
if bracketIdx := strings.Index(expr, "["); bracketIdx >= 0 {
closeIdx := -1
// Find matching ] — must balance nested $((…)) parens inside the index
depth := 0
for k := bracketIdx + 1; k < len(expr); k++ {
if expr[k] == '(' {
depth++
} else if expr[k] == ')' {
depth--
} else if expr[k] == ']' && depth == 0 {
closeIdx = k
break
}
}
if closeIdx > bracketIdx {
prefix := expr[:bracketIdx] prefix := expr[:bracketIdx]
hasOp := strings.ContainsAny(prefix, ":-:=:+%#") // No operator before the bracket
if !hasOp { if !strings.ContainsAny(prefix, ":=+%#") {
arrName := prefix arrName := prefix
idx := expr[bracketIdx+1 : len(expr)-1] idx := expr[bracketIdx+1 : closeIdx]
if idx == "@" { rest := expr[closeIdx+1:] // may be empty or ":-…" / ":+…" / ":=…"
arrVal := ""
isMulti := false
multiVal := ""
switch idx {
case "@":
arr := s.getArray(arrName) arr := s.getArray(arrName)
return strings.Join(arr, "\x01") if len(arr) == 0 {
arrVal = ""
} else {
isMulti = true
multiVal = strings.Join(arr, "\x01")
} }
if idx == "*" { case "*":
arr := s.getArray(arrName) arr := s.getArray(arrName)
return strings.Join(arr, " ") arrVal = strings.Join(arr, " ")
} default:
// Numeric index
n := s.evalArith(idx) n := s.evalArith(idx)
arr := s.getArray(arrName) arr := s.getArray(arrName)
if n >= 0 && n < len(arr) { if n >= 0 && n < len(arr) {
return arr[n] arrVal = arr[n]
}
}
// Apply trailing operator (:- := :+) if present
switch {
case rest == "":
if isMulti {
if multiVal == "" {
return "\x01"
}
return multiVal
}
return arrVal
case strings.HasPrefix(rest, ":-"):
if isMulti {
return multiVal
}
if arrVal != "" {
return arrVal
}
return s.expandWord(rest[2:])
case strings.HasPrefix(rest, ":="):
if arrVal != "" {
return arrVal
}
expanded := s.expandWord(rest[2:])
s.vars[arrName] = expanded
return expanded
case strings.HasPrefix(rest, ":+"):
if isMulti {
return s.expandWord(rest[2:])
}
if arrVal != "" {
return s.expandWord(rest[2:])
} }
return "" return ""
default:
if isMulti {
return multiVal
}
return arrVal
}
}
} }
} }
@@ -576,6 +645,17 @@ func (s *Shell) tokenize(input string) []string {
inDouble = !inDouble inDouble = !inDouble
wasQuoted = true wasQuoted = true
current.WriteByte(c) current.WriteByte(c)
// Process substitution <(...): when '<' is immediately followed by '('
// (no space between), keep the entire <(cmd args...) as one token so
// extractRedirects can run the command and redirect stdin to its output.
// The standalone '<' redirect operator always has a space after it and
// therefore becomes its own token before this case is reached.
case c == '<' && !inSingle && !inDouble && parenDepth == 0 &&
i+1 < len(input) && input[i+1] == '(':
current.WriteByte('<')
current.WriteByte('(')
parenDepth++
i++ // skip '('; outer loop will add 1 more → 2 chars consumed
case c == '$' && !inSingle && i+1 < len(input) && (input[i+1] == '(' || input[i+1] == '{'): case c == '$' && !inSingle && i+1 < len(input) && (input[i+1] == '(' || input[i+1] == '{'):
// Mark that the next ( opens a substitution — don't increment depth here // Mark that the next ( opens a substitution — don't increment depth here
if input[i+1] == '(' { if input[i+1] == '(' {
@@ -633,8 +713,17 @@ doneTokenizing:
tok = tok[2:] tok = tok[2:]
} }
expanded := s.expandWord(tok) expanded := s.expandWord(tok)
// Handle multi-word expansion from $@ and ${arr[@]} // Handle multi-word expansion from $@ and ${arr[@]}.
// Exception: process substitution tokens (<(...)) must stay as one
// token so extractRedirects can recognise them. Inside a process
// substitution the \x01 separators are argument boundaries for the
// inner command, so join them with spaces instead of splitting.
if strings.Contains(expanded, "\x01") { if strings.Contains(expanded, "\x01") {
if strings.HasPrefix(expanded, "<(") {
expanded = strings.ReplaceAll(expanded, "\x01", " ")
result = append(result, expanded)
continue
}
parts := strings.Split(expanded, "\x01") parts := strings.Split(expanded, "\x01")
for _, p := range parts { for _, p := range parts {
if p != "" { if p != "" {

View File

@@ -119,6 +119,8 @@ func (s *Shell) SetVar(name, value string) {
} }
// Execute runs commands from the given input string. // Execute runs commands from the given input string.
func PreprocessForTest(input string) string { return preprocessHeredocs(input) }
func ParseBlocksForTest(input string) []string { return parseBlocks(input) }
func (s *Shell) Execute(input string) error { func (s *Shell) Execute(input string) error {
// Normalize CRLF to LF // Normalize CRLF to LF
input = strings.ReplaceAll(input, "\r\n", "\n") input = strings.ReplaceAll(input, "\r\n", "\n")
@@ -206,9 +208,9 @@ func parseBlocks(input string) []string {
funcKwDepth = 0 funcKwDepth = 0
for _, p := range splitStatements(stmt[braceIdx+1:]) { for _, p := range splitStatements(stmt[braceIdx+1:]) {
switch firstWord(p) { switch firstWord(p) {
case "if", "for", "while", "until", "case": case "if", "for", "while", "until", "case", "{":
funcKwDepth++ funcKwDepth++
case "fi", "done", "esac": case "fi", "done", "esac", "}":
funcKwDepth-- funcKwDepth--
} }
} }
@@ -226,7 +228,7 @@ func parseBlocks(input string) []string {
} }
switch w { switch w {
case "if", "for", "while", "until", "case": case "if", "for", "while", "until", "case", "{":
kwDepth++ kwDepth++
} }
kwDepth += embeddedKwDepth(stmt) kwDepth += embeddedKwDepth(stmt)
@@ -255,9 +257,9 @@ func parseBlocks(input string) []string {
continue continue
} }
switch w { switch w {
case "if", "for", "while", "until", "case": case "if", "for", "while", "until", "case", "{":
funcKwDepth++ funcKwDepth++
case "fi", "done", "esac": case "fi", "done", "esac", "}":
funcKwDepth-- funcKwDepth--
} }
funcKwDepth += embeddedKwDepth(stmt) funcKwDepth += embeddedKwDepth(stmt)
@@ -273,6 +275,7 @@ func parseBlocks(input string) []string {
// embeddedKwDepth returns the net depth change from keywords that appear // embeddedKwDepth returns the net depth change from keywords that appear
// after do/then/else/elif within a single statement (excluding the first word, // after do/then/else/elif within a single statement (excluding the first word,
// which is handled separately by the caller). // which is handled separately by the caller).
func EmbeddedKwDepthForTest(s string) int { return embeddedKwDepth(s) }
func embeddedKwDepth(stmt string) int { func embeddedKwDepth(stmt string) int {
words := strings.Fields(stmt) words := strings.Fields(stmt)
delta := 0 delta := 0
@@ -375,6 +378,12 @@ func parseHeredocMarkers(line string) (string, []string) {
out.WriteByte(c) out.WriteByte(c)
i++ i++
case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '<': case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '<':
// <<< here-string: not a heredoc, pass through all three < chars unchanged
if i+2 < len(line) && line[i+2] == '<' {
out.WriteString("<<<")
i += 3
break
}
// Possible heredoc // Possible heredoc
i += 2 // skip << i += 2 // skip <<
stripTabs := false stripTabs := false
@@ -429,6 +438,7 @@ func splitStatements(input string) []string {
inSingle := false inSingle := false
inDouble := false inDouble := false
parenDepth := 0 parenDepth := 0
braceDepth := 0 // { } command groups — don't split ; inside them
for i := 0; i < len(input); i++ { for i := 0; i < len(input); i++ {
c := input[i] c := input[i]
@@ -445,7 +455,14 @@ func splitStatements(input string) []string {
case c == ')' && !inSingle && !inDouble && parenDepth > 0: case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth-- parenDepth--
current.WriteByte(c) current.WriteByte(c)
case c == ';' && !inSingle && !inDouble && parenDepth == 0: // Track { } command groups but not ${...} variable expansions
case c == '{' && !inSingle && !inDouble && parenDepth == 0 && (i == 0 || input[i-1] != '$'):
braceDepth++
current.WriteByte(c)
case c == '}' && !inSingle && !inDouble && parenDepth == 0 && braceDepth > 0:
braceDepth--
current.WriteByte(c)
case c == ';' && !inSingle && !inDouble && parenDepth == 0 && braceDepth == 0:
if i+1 < len(input) && input[i+1] == ';' { if i+1 < len(input) && input[i+1] == ';' {
// Double semicolon — flush current token, then emit ";;" as a token // Double semicolon — flush current token, then emit ";;" as a token
if s := strings.TrimSpace(current.String()); s != "" { if s := strings.TrimSpace(current.String()); s != "" {
@@ -461,7 +478,7 @@ func splitStatements(input string) []string {
} }
current.Reset() current.Reset()
} }
case c == '\n' && !inSingle && !inDouble && parenDepth == 0: case c == '\n' && !inSingle && !inDouble && parenDepth == 0 && braceDepth == 0:
if s := strings.TrimSpace(current.String()); s != "" { if s := strings.TrimSpace(current.String()); s != "" {
result = append(result, s) result = append(result, s)
} }
@@ -553,12 +570,15 @@ func (s *Shell) executeBlock(block string) error {
if isFuncDefStart(block) { if isFuncDefStart(block) {
return s.defineFunction(block) return s.defineFunction(block)
} }
for _, line := range strings.Split(block, "\n") { // Use splitStatements instead of strings.Split("\n") so that multi-line
line = strings.TrimSpace(line) // constructs (e.g. process substitutions spanning several lines) are kept
if line == "" || strings.HasPrefix(line, "#") { // together as a single logical unit rather than being broken apart.
for _, stmt := range splitStatements(block) {
stmt = strings.TrimSpace(stmt)
if stmt == "" || strings.HasPrefix(stmt, "#") {
continue continue
} }
if err := s.executeLine(line); err != nil { if err := s.executeLine(stmt); err != nil {
return err return err
} }
} }
@@ -591,20 +611,40 @@ func splitBySemicolon(line string) []string {
current := strings.Builder{} current := strings.Builder{}
inSingle := false inSingle := false
inDouble := false inDouble := false
parenDepth := 0 // tracks $(...) and <(...) nesting
pendingDollar := false
for i := 0; i < len(line); i++ { for i := 0; i < len(line); i++ {
c := line[i] c := line[i]
switch { switch {
case c == '\'' && !inDouble: case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle inSingle = !inSingle
current.WriteByte(c) current.WriteByte(c)
case c == '"' && !inSingle: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
case c == ';' && !inSingle && !inDouble: // Process substitution <(...): don't split ; | && || inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
// Command substitution $(...): don't split ; inside.
case c == '$' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
pendingDollar = true
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == ';' && !inSingle && !inDouble && parenDepth == 0:
parts = append(parts, current.String()) parts = append(parts, current.String())
current.Reset() current.Reset()
default: default:
pendingDollar = false
current.WriteByte(c) current.WriteByte(c)
} }
} }
@@ -625,37 +665,58 @@ func (s *Shell) executeAndOrList(line string) error {
inSingle := false inSingle := false
inDouble := false inDouble := false
dbDepth := 0 // double-bracket [[ depth dbDepth := 0 // double-bracket [[ depth
parenDepth := 0 // $( ) depth — don't split && || inside subshells
pendingDollar := false
for i := 0; i < len(line); i++ { for i := 0; i < len(line); i++ {
c := line[i] c := line[i]
switch { switch {
case c == '\'' && !inDouble: case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle inSingle = !inSingle
current.WriteByte(c) current.WriteByte(c)
case c == '"' && !inSingle: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
case c == '[' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '[': // Process substitution <(...): don't split && || inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
case c == '$' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
pendingDollar = true
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == '[' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == '[':
dbDepth++ dbDepth++
current.WriteByte(c) current.WriteByte(c)
current.WriteByte(line[i+1]) current.WriteByte(line[i+1])
i++ i++
case c == ']' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == ']' && dbDepth > 0: case c == ']' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == ']' && dbDepth > 0:
dbDepth-- dbDepth--
current.WriteByte(c) current.WriteByte(c)
current.WriteByte(line[i+1]) current.WriteByte(line[i+1])
i++ i++
case c == '&' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '&' && dbDepth == 0: case c == '&' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == '&' && dbDepth == 0:
pendingDollar = false
tokens = append(tokens, tok{current.String(), op}) tokens = append(tokens, tok{current.String(), op})
current.Reset() current.Reset()
op = "&&" op = "&&"
i++ i++
case c == '|' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '|' && dbDepth == 0: case c == '|' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == '|' && dbDepth == 0:
pendingDollar = false
tokens = append(tokens, tok{current.String(), op}) tokens = append(tokens, tok{current.String(), op})
current.Reset() current.Reset()
op = "||" op = "||"
i++ i++
default: default:
pendingDollar = false
current.WriteByte(c) current.WriteByte(c)
} }
} }