diff --git a/.gitignore b/.gitignore index d14335b..84686af 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ release/ *.exe *.o tmp/ +waifufetch/ diff --git a/README.md b/README.md index 44578de..9b8cd7a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,42 @@ bash script.sh # run a script file 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 ```bash diff --git a/cmd/bash/main.go b/cmd/bash/main.go index 9812697..a87ae69 100644 --- a/cmd/bash/main.go +++ b/cmd/bash/main.go @@ -43,7 +43,7 @@ func runFile(path string, args []string) error { return fmt.Errorf("%s: %v", path, err) } sh := shell.New() - sh.SetArgs(append([]string{path}, args...)) + sh.SetArgs(args) sh.SetVar("0", path) return sh.Execute(string(data)) } diff --git a/internal/shell/builtins.go b/internal/shell/builtins.go index 52793ec..6d879e7 100644 --- a/internal/shell/builtins.go +++ b/internal/shell/builtins.go @@ -18,6 +18,8 @@ var aliases = make(map[string]string) func (s *Shell) initBuiltins() { s.builtins = map[string]func([]string) error{ + "{": s.builtinTrue, + "}": s.builtinTrue, // Shell builtins "cd": s.builtinCd, "pwd": s.builtinPwd, @@ -442,84 +444,111 @@ func (s *Shell) builtinPrintf(args []string) error { fmtArgs := args[1:] 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 - i := 0 - for i < len(format) { - if format[i] == '%' && i+1 < len(format) { - i++ - // Optional width/precision - specStart := i - for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') { + for { + consumed := 0 + i := 0 + for i < len(format) { + if format[i] == '%' && i+1 < len(format) { + i++ + // Optional width/precision flags + specStart := i + for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') { + i++ + } + spec := format[specStart:i] + if i >= len(format) { + break + } + arg := "" + if argIdx < len(fmtArgs) { + arg = fmtArgs[argIdx] + argIdx++ + consumed++ + } + switch format[i] { + case 's': + if spec != "" { + result.WriteString(fmt.Sprintf("%-"+spec+"s", arg)) //nolint + } else { + result.WriteString(arg) + } + case 'd': + n := toInt(arg) + if spec != "" { + result.WriteString(fmt.Sprintf("%"+spec+"d", n)) + } else { + result.WriteString(strconv.Itoa(n)) + } + case 'f': + f, _ := strconv.ParseFloat(arg, 64) + if spec != "" { + result.WriteString(fmt.Sprintf("%"+spec+"f", f)) + } else { + result.WriteString(fmt.Sprintf("%f", f)) + } + case 'x': + n := toInt(arg) + result.WriteString(fmt.Sprintf("%x", n)) + case 'o': + n := toInt(arg) + result.WriteString(fmt.Sprintf("%o", n)) + case '%': + result.WriteByte('%') + if consumed > 0 { + consumed-- // %% consumes no arg + } + default: + result.WriteByte('%') + result.WriteByte(format[i]) + if consumed > 0 { + consumed-- + } + } + i++ + } else if format[i] == '\\' && i+1 < len(format) { + i++ + switch format[i] { + case 'n': + result.WriteByte('\n') + case 't': + result.WriteByte('\t') + case 'r': + result.WriteByte('\r') + case '\\': + result.WriteByte('\\') + case 'a': + result.WriteByte('\a') + case '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: + result.WriteByte('\\') + result.WriteByte(format[i]) + } + i++ + } else { + result.WriteByte(format[i]) i++ } - spec := format[specStart:i] - if i >= len(format) { - break - } - arg := "" - if argIdx < len(fmtArgs) { - arg = fmtArgs[argIdx] - argIdx++ - } - switch format[i] { - case 's': - if spec != "" { - result.WriteString(fmt.Sprintf("%-"+spec+"s", arg)) //nolint - } else { - result.WriteString(arg) - } - case 'd': - n := toInt(arg) - if spec != "" { - result.WriteString(fmt.Sprintf("%"+spec+"d", n)) - } else { - result.WriteString(strconv.Itoa(n)) - } - case 'f': - f, _ := strconv.ParseFloat(arg, 64) - if spec != "" { - result.WriteString(fmt.Sprintf("%"+spec+"f", f)) - } else { - result.WriteString(fmt.Sprintf("%f", f)) - } - case 'x': - n := toInt(arg) - result.WriteString(fmt.Sprintf("%x", n)) - case 'o': - n := toInt(arg) - result.WriteString(fmt.Sprintf("%o", n)) - case '%': - result.WriteByte('%') - argIdx-- // no arg consumed - default: - result.WriteByte('%') - result.WriteByte(format[i]) - argIdx-- - } - i++ - } else if format[i] == '\\' && i+1 < len(format) { - i++ - switch format[i] { - case 'n': - result.WriteByte('\n') - case 't': - result.WriteByte('\t') - case 'r': - result.WriteByte('\r') - case '\\': - result.WriteByte('\\') - case 'a': - result.WriteByte('\a') - case 'b': - result.WriteByte('\b') - default: - result.WriteByte('\\') - result.WriteByte(format[i]) - } - i++ - } else { - result.WriteByte(format[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()) diff --git a/internal/shell/control.go b/internal/shell/control.go index 7518173..fd6e80b 100644 --- a/internal/shell/control.go +++ b/internal/shell/control.go @@ -21,17 +21,40 @@ func (s *Shell) executeIf(block string) error { phase := "if_cond" var curCond []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 { w := firstWord(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 { case w == "if" && phase == "if_cond": if rest != "" { curCond = append(curCond, rest) } - case w == "then": + case w == "then" && (phase == "if_cond" || phase == "elif_cond"): if rest != "" { curBody = append(curBody, rest) } @@ -67,8 +90,17 @@ func (s *Shell) executeIf(block string) error { curCond = append(curCond, stmt) case "body": curBody = append(curBody, stmt) + // Track depth for nested blocks starting in body + switch w { + case "if", "for", "while", "until", "case": + depth++ + } case "else": elseBody = append(elseBody, stmt) + switch w { + case "if", "for", "while", "until", "case": + depth++ + } } } } diff --git a/internal/shell/exec.go b/internal/shell/exec.go index 17eb639..3756a94 100644 --- a/internal/shell/exec.go +++ b/internal/shell/exec.go @@ -58,6 +58,12 @@ func splitPipe(input string) []string { case c == '"' && !inSingle && parenDepth == 0: inDouble = !inDouble 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] == '(': pendingDollar = true current.WriteByte(c) @@ -154,6 +160,15 @@ func (s *Shell) executeCommand(input string) error { 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+=(...) if name, appendMode, elems, ok := s.parseArrayAssign(input); ok { if appendMode { @@ -264,6 +279,20 @@ func (s *Shell) extractRedirects(tokens []string) ([]string, []redirect) { case strings.HasPrefix(tok, ">") && len(tok) > 1: redirects = append(redirects, redirect{1, ">", tok[1:]}) 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 <(...) case tok == "<": 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 { case ">": 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) 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.Stdin = s.Stdin cmd.Stdout = s.Stdout @@ -374,6 +421,25 @@ func (s *Shell) executeExternal(cmdName string, args []string) error { 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 { tokens := s.tokenize(input) if len(tokens) == 0 { diff --git a/internal/shell/expand.go b/internal/shell/expand.go index fa22537..c60641c 100644 --- a/internal/shell/expand.go +++ b/internal/shell/expand.go @@ -82,7 +82,6 @@ func (s *Shell) expandWord(word string) string { result.WriteByte('$') break } - // $'...' ANSI C string if word[i] == '\'' { i++ // skip opening ' @@ -232,7 +231,11 @@ func (s *Shell) expandWord(word string) string { result.WriteString(s.vars["#"]) i++ case '@': - result.WriteString(strings.Join(s.args, "\x01")) + if len(s.args) == 0 { + result.WriteString("\x01") // empty-expansion sentinel → 0 tokens + } else { + result.WriteString(strings.Join(s.args, "\x01")) + } i++ case '*': result.WriteString(s.vars["*"]) @@ -278,29 +281,95 @@ func (s *Shell) evalVarExpr(expr string) string { return strconv.Itoa(len(s.getVar(rest))) } - // Array indexing: ${arr[@]}, ${arr[*]}, ${arr[N]} - if bracketIdx := strings.Index(expr, "["); bracketIdx >= 0 && strings.HasSuffix(expr, "]") { - // Make sure there's no operator before the bracket - prefix := expr[:bracketIdx] - hasOp := strings.ContainsAny(prefix, ":-:=:+%#") - if !hasOp { - arrName := prefix - idx := expr[bracketIdx+1 : len(expr)-1] - if idx == "@" { - arr := s.getArray(arrName) - return strings.Join(arr, "\x01") + // Array indexing: ${arr[@]}, ${arr[*]}, ${arr[N]}, ${arr[N]:-default}, etc. + // Find the bracket pair and evaluate the array access even when an operator + // (:- := :+) 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 idx == "*" { - arr := s.getArray(arrName) - return strings.Join(arr, " ") + } + if closeIdx > bracketIdx { + prefix := expr[:bracketIdx] + // No operator before the bracket + if !strings.ContainsAny(prefix, ":=+%#") { + arrName := prefix + idx := expr[bracketIdx+1 : closeIdx] + rest := expr[closeIdx+1:] // may be empty or ":-…" / ":+…" / ":=…" + + arrVal := "" + isMulti := false + multiVal := "" + + switch idx { + case "@": + arr := s.getArray(arrName) + if len(arr) == 0 { + arrVal = "" + } else { + isMulti = true + multiVal = strings.Join(arr, "\x01") + } + case "*": + arr := s.getArray(arrName) + arrVal = strings.Join(arr, " ") + default: + n := s.evalArith(idx) + arr := s.getArray(arrName) + if n >= 0 && n < len(arr) { + 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 "" + default: + if isMulti { + return multiVal + } + return arrVal + } } - // Numeric index - n := s.evalArith(idx) - arr := s.getArray(arrName) - if n >= 0 && n < len(arr) { - return arr[n] - } - return "" } } @@ -576,6 +645,17 @@ func (s *Shell) tokenize(input string) []string { inDouble = !inDouble wasQuoted = true 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] == '{'): // Mark that the next ( opens a substitution — don't increment depth here if input[i+1] == '(' { @@ -633,8 +713,17 @@ doneTokenizing: tok = tok[2:] } 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.HasPrefix(expanded, "<(") { + expanded = strings.ReplaceAll(expanded, "\x01", " ") + result = append(result, expanded) + continue + } parts := strings.Split(expanded, "\x01") for _, p := range parts { if p != "" { diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 647e78f..aec5490 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -119,6 +119,8 @@ func (s *Shell) SetVar(name, value 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 { // Normalize CRLF to LF input = strings.ReplaceAll(input, "\r\n", "\n") @@ -206,9 +208,9 @@ func parseBlocks(input string) []string { funcKwDepth = 0 for _, p := range splitStatements(stmt[braceIdx+1:]) { switch firstWord(p) { - case "if", "for", "while", "until", "case": + case "if", "for", "while", "until", "case", "{": funcKwDepth++ - case "fi", "done", "esac": + case "fi", "done", "esac", "}": funcKwDepth-- } } @@ -226,7 +228,7 @@ func parseBlocks(input string) []string { } switch w { - case "if", "for", "while", "until", "case": + case "if", "for", "while", "until", "case", "{": kwDepth++ } kwDepth += embeddedKwDepth(stmt) @@ -255,9 +257,9 @@ func parseBlocks(input string) []string { continue } switch w { - case "if", "for", "while", "until", "case": + case "if", "for", "while", "until", "case", "{": funcKwDepth++ - case "fi", "done", "esac": + case "fi", "done", "esac", "}": funcKwDepth-- } funcKwDepth += embeddedKwDepth(stmt) @@ -273,6 +275,7 @@ func parseBlocks(input string) []string { // embeddedKwDepth returns the net depth change from keywords that appear // after do/then/else/elif within a single statement (excluding the first word, // which is handled separately by the caller). +func EmbeddedKwDepthForTest(s string) int { return embeddedKwDepth(s) } func embeddedKwDepth(stmt string) int { words := strings.Fields(stmt) delta := 0 @@ -375,6 +378,12 @@ func parseHeredocMarkers(line string) (string, []string) { out.WriteByte(c) i++ 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 i += 2 // skip << stripTabs := false @@ -429,6 +438,7 @@ func splitStatements(input string) []string { inSingle := false inDouble := false parenDepth := 0 + braceDepth := 0 // { } command groups — don't split ; inside them for i := 0; i < len(input); i++ { c := input[i] @@ -445,7 +455,14 @@ func splitStatements(input string) []string { case c == ')' && !inSingle && !inDouble && parenDepth > 0: parenDepth-- 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] == ';' { // Double semicolon — flush current token, then emit ";;" as a token if s := strings.TrimSpace(current.String()); s != "" { @@ -461,7 +478,7 @@ func splitStatements(input string) []string { } 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 != "" { result = append(result, s) } @@ -553,12 +570,15 @@ func (s *Shell) executeBlock(block string) error { if isFuncDefStart(block) { return s.defineFunction(block) } - for _, line := range strings.Split(block, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { + // Use splitStatements instead of strings.Split("\n") so that multi-line + // constructs (e.g. process substitutions spanning several lines) are kept + // 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 } - if err := s.executeLine(line); err != nil { + if err := s.executeLine(stmt); err != nil { return err } } @@ -591,20 +611,40 @@ func splitBySemicolon(line string) []string { current := strings.Builder{} inSingle := false inDouble := false + parenDepth := 0 // tracks $(...) and <(...) nesting + pendingDollar := false for i := 0; i < len(line); i++ { c := line[i] switch { - case c == '\'' && !inDouble: + case c == '\'' && !inDouble && parenDepth == 0: inSingle = !inSingle current.WriteByte(c) - case c == '"' && !inSingle: + case c == '"' && !inSingle && parenDepth == 0: inDouble = !inDouble 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()) current.Reset() default: + pendingDollar = false current.WriteByte(c) } } @@ -624,38 +664,59 @@ func (s *Shell) executeAndOrList(line string) error { op := "" inSingle := 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++ { c := line[i] switch { - case c == '\'' && !inDouble: + case c == '\'' && !inDouble && parenDepth == 0: inSingle = !inSingle current.WriteByte(c) - case c == '"' && !inSingle: + case c == '"' && !inSingle && parenDepth == 0: inDouble = !inDouble 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++ current.WriteByte(c) current.WriteByte(line[i+1]) 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-- current.WriteByte(c) current.WriteByte(line[i+1]) 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}) current.Reset() op = "&&" 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}) current.Reset() op = "||" i++ default: + pendingDollar = false current.WriteByte(c) } }