Compare commits
8 Commits
11162130a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9037606447 | ||
|
|
7b3a101946 | ||
| 114cbf43bd | |||
|
|
66cf9f15d2 | ||
|
|
3b502f5516 | ||
|
|
dd388a7469 | ||
|
|
8c6a2ab4c2 | ||
|
|
eba49c46bc |
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-gnullvm]
|
||||||
|
linker = "x86_64-w64-mingw32-clang"
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
build/
|
||||||
|
release/
|
||||||
|
*.exe
|
||||||
|
*.o
|
||||||
|
tmp/
|
||||||
|
waifufetch/
|
||||||
163
README.md
163
README.md
@@ -1,3 +1,164 @@
|
|||||||
# bash-for-windows
|
# bash-for-windows
|
||||||
|
|
||||||
Fully functional bash for windows
|
A bash-compatible shell for Windows, written in Go. Runs natively as a single `.exe` — no WSL, Cygwin, or MSYS2 required.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Build first**, then run the installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh --release # produces release/bash.exe + installer scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the `release/` folder to your Windows machine, then double-click **`install.bat`**.
|
||||||
|
|
||||||
|
The installer:
|
||||||
|
- Copies `bash.exe` to `%LOCALAPPDATA%\Programs\BashForWindows\`
|
||||||
|
- Adds it to your user **PATH**
|
||||||
|
- Registers a **"Bash for Windows"** profile in Windows Terminal so it shows up in the `+` dropdown
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Optional flags
|
||||||
|
.\install.ps1 -InstallDir "C:\Tools\bash" # custom install path
|
||||||
|
.\install.ps1 -NoTerminal # skip Windows Terminal profile
|
||||||
|
.\install.ps1 -Uninstall # clean removal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Shell language
|
||||||
|
- **Control flow** — `if/elif/else/fi`, `for/do/done`, `while/until`
|
||||||
|
- **Functions** — `name() { ... }` and `function name { ... }`, with `local`/`return`
|
||||||
|
- **Arithmetic** — `$(( expr ))`, `$((i + 1))`, `$((n % 2))`
|
||||||
|
- **Command substitution** — `$(cmd)`, including pipelines: `x=$(echo foo | tr a-z A-Z)`
|
||||||
|
- **Variable expansion** — `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR:=val}`, `${#VAR}`, `${VAR%suffix}`, `${VAR#prefix}`
|
||||||
|
- **Glob expansion** — `*.txt`, `src/**`
|
||||||
|
- **Tilde expansion** — `~/Documents`
|
||||||
|
- **Quotes** — single `'literal'`, double `"with $vars"`, backslash escapes
|
||||||
|
- **Inline comments** — `echo hello # this is ignored`
|
||||||
|
|
||||||
|
### I/O
|
||||||
|
- **Pipelines** — `cmd1 | cmd2 | cmd3`
|
||||||
|
- **Redirection** — `>`, `>>`, `<`, `2>`, `2>&1`, `&>`
|
||||||
|
- **Background jobs** — `cmd &`
|
||||||
|
- **Command chaining** — `&&`, `||`, `;`
|
||||||
|
|
||||||
|
### Interactive
|
||||||
|
- **Command history** with arrow keys (saved to `~/.bash_history`)
|
||||||
|
- **Tab completion** for commands and file paths
|
||||||
|
- **Multi-line input** — `if`/`for`/`while`/functions continue on the next line
|
||||||
|
- **Prompt** shows current directory and last exit code when non-zero
|
||||||
|
|
||||||
|
### Built-in commands
|
||||||
|
| Category | Commands |
|
||||||
|
|----------|----------|
|
||||||
|
| Shell | `cd`, `pwd`, `echo`, `exit`, `export`, `set`, `unset`, `source`/`.`, `alias`, `unalias`, `type`, `command`, `which`, `env` |
|
||||||
|
| Control | `true`, `false`, `test`/`[`, `break`, `continue`, `return`, `shift`, `read`, `printf` |
|
||||||
|
| Variables | `declare`, `local` |
|
||||||
|
| Files | `ls`, `cat`, `cp`, `mv`, `rm`, `mkdir`, `touch`, `find`, `basename`, `dirname` |
|
||||||
|
| Text | `grep`, `sed`, `sort`, `uniq`, `wc`, `head`, `tail`, `cut`, `tr`, `tee`, `xargs` |
|
||||||
|
| System | `date`, `sleep`, `clear`, `jobs` |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
bash # interactive shell
|
||||||
|
bash -c 'echo hello' # run a command string
|
||||||
|
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
|
||||||
|
# Variables and arithmetic
|
||||||
|
name="World"
|
||||||
|
echo "Hello $name"
|
||||||
|
echo $((40 + 2))
|
||||||
|
|
||||||
|
# Loops and functions
|
||||||
|
is_even() {
|
||||||
|
if [ $(($1 % 2)) -eq 0 ]; then return 0; else return 1; fi
|
||||||
|
}
|
||||||
|
for n in 1 2 3 4 5 6; do
|
||||||
|
if is_even $n; then echo "$n is even"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Pipelines and redirection
|
||||||
|
printf "127.0.0.1 localhost\n127.0.0.2 example\n" | grep localhost | wc -l
|
||||||
|
find . -name "*.go" | xargs grep "TODO" > todo_files.txt
|
||||||
|
echo "result=$(date)" >> log.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Requires Go 1.21+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # builds Linux debug + Windows .exe into build/
|
||||||
|
./build.sh --release # also creates release/ folder ready to distribute
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual cross-compile
|
||||||
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bash.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/bash/ entry point, readline REPL
|
||||||
|
internal/shell/
|
||||||
|
shell.go Execute, parseBlocks, splitStatements, IsIncomplete
|
||||||
|
expand.go expandWord, tokenize, arithmetic, glob
|
||||||
|
exec.go pipelines, redirections, external commands
|
||||||
|
control.go if/for/while/until, function define/call
|
||||||
|
builtins.go all built-in commands and coreutils
|
||||||
|
install.ps1 Windows installer (PATH + Windows Terminal profile)
|
||||||
|
install.bat double-click wrapper for install.ps1
|
||||||
|
build.sh build script
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
# NOTE:
|
||||||
|
|
||||||
|
Just because this is a port of Bash does not mean every Linux/Unix command will work out of the box. While the core Bash syntax, logic, and a set of built-in commands have been implemented, any external command requires either a native Windows executable or a dedicated port to function.
|
||||||
40
build.sh
Executable file
40
build.sh
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUILD_DIR="build"
|
||||||
|
RELEASE_DIR="release"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
echo "=== Building bash-for-windows ==="
|
||||||
|
|
||||||
|
# Linux debug build
|
||||||
|
echo " -> Linux (debug)..."
|
||||||
|
go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows" .
|
||||||
|
|
||||||
|
# Windows release build
|
||||||
|
echo " -> Windows (x86_64)..."
|
||||||
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
|
||||||
|
go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows.exe" .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build complete ==="
|
||||||
|
ls -lh "$BUILD_DIR/"
|
||||||
|
|
||||||
|
# ── Release package ───────────────────────────────────────────────────────────
|
||||||
|
if [[ "${1:-}" == "--release" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Building release package ==="
|
||||||
|
rm -rf "$RELEASE_DIR"
|
||||||
|
mkdir -p "$RELEASE_DIR"
|
||||||
|
|
||||||
|
cp "$BUILD_DIR/bash-windows.exe" "$RELEASE_DIR/bash.exe"
|
||||||
|
cp install.ps1 "$RELEASE_DIR/"
|
||||||
|
cp install.bat "$RELEASE_DIR/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Release folder: $RELEASE_DIR/"
|
||||||
|
ls -lh "$RELEASE_DIR/"
|
||||||
|
echo ""
|
||||||
|
echo "Distribute the contents of $RELEASE_DIR/ as a zip."
|
||||||
|
echo "Users run install.bat (or install.ps1) to install."
|
||||||
|
fi
|
||||||
306
cmd/bash/main.go
Normal file
306
cmd/bash/main.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package bash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
|
"github.com/cametendo/bash-for-windows/internal/shell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
args := os.Args[1:]
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "-c":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("-c: option requires an argument")
|
||||||
|
}
|
||||||
|
sh := shell.New()
|
||||||
|
if len(args) > 2 {
|
||||||
|
sh.SetArgs(args[2:])
|
||||||
|
}
|
||||||
|
return sh.Execute(strings.Join(args[1:], " "))
|
||||||
|
case "--version":
|
||||||
|
fmt.Println("bash-for-windows 2.0.0 (Go-based)")
|
||||||
|
fmt.Println("Provides bash-compatible shell for Windows.")
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return runFile(args[0], args[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return interactive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFile(path string, args []string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
sh := shell.New()
|
||||||
|
sh.SetArgs(args)
|
||||||
|
sh.SetVar("0", path)
|
||||||
|
return sh.Execute(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyFile() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// On Windows this lands in %USERPROFILE%\.bash_history
|
||||||
|
return filepath.Join(home, ".bash_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactive() error {
|
||||||
|
sh := shell.New()
|
||||||
|
|
||||||
|
completer := &shellCompleter{sh: sh}
|
||||||
|
|
||||||
|
rl, err := readline.NewEx(&readline.Config{
|
||||||
|
HistoryFile: historyFile(),
|
||||||
|
AutoComplete: completer,
|
||||||
|
InterruptPrompt: "^C",
|
||||||
|
EOFPrompt: "exit",
|
||||||
|
HistorySearchFold: true,
|
||||||
|
FuncFilterInputRune: filterInput,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to dumb interactive mode
|
||||||
|
return interactiveDumb(sh)
|
||||||
|
}
|
||||||
|
defer rl.Close()
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stdout, "bash-for-windows v2.0.0 (type 'exit' or Ctrl+D to quit)")
|
||||||
|
|
||||||
|
var multiLine strings.Builder
|
||||||
|
|
||||||
|
for {
|
||||||
|
prompt := buildPrompt(sh)
|
||||||
|
if multiLine.Len() > 0 {
|
||||||
|
prompt = "> "
|
||||||
|
}
|
||||||
|
rl.SetPrompt(prompt)
|
||||||
|
|
||||||
|
line, err := rl.Readline()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "Interrupt" {
|
||||||
|
// Ctrl+C: abort current multi-line input
|
||||||
|
multiLine.Reset()
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break // EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if multiLine.Len() > 0 {
|
||||||
|
multiLine.WriteString("\n")
|
||||||
|
}
|
||||||
|
multiLine.WriteString(line)
|
||||||
|
|
||||||
|
input := multiLine.String()
|
||||||
|
if shell.IsIncomplete(input) {
|
||||||
|
continue // wait for more input
|
||||||
|
}
|
||||||
|
multiLine.Reset()
|
||||||
|
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sh.Execute(input); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "bash: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactiveDumb is a fallback that doesn't need readline.
|
||||||
|
func interactiveDumb(sh *shell.Shell) error {
|
||||||
|
fmt.Fprintln(os.Stdout, "bash-for-windows v2.0.0")
|
||||||
|
|
||||||
|
var multiLine strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
|
||||||
|
for {
|
||||||
|
prompt := buildPrompt(sh)
|
||||||
|
if multiLine.Len() > 0 {
|
||||||
|
prompt = "> "
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stdout, prompt)
|
||||||
|
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := string(buf[:n])
|
||||||
|
if multiLine.Len() > 0 {
|
||||||
|
multiLine.WriteString("\n")
|
||||||
|
}
|
||||||
|
multiLine.WriteString(strings.TrimRight(chunk, "\r\n"))
|
||||||
|
|
||||||
|
input := multiLine.String()
|
||||||
|
if shell.IsIncomplete(input) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
multiLine.Reset()
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if execErr := sh.Execute(input); execErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "bash: %v\n", execErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPrompt(sh *shell.Shell) string {
|
||||||
|
pwd, _ := os.Getwd()
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" && strings.HasPrefix(pwd, home) {
|
||||||
|
pwd = "~" + pwd[len(home):]
|
||||||
|
}
|
||||||
|
// Show exit code in prompt if non-zero
|
||||||
|
exitCode := sh.GetVar("?")
|
||||||
|
suffix := "$ "
|
||||||
|
if exitCode != "0" && exitCode != "" {
|
||||||
|
suffix = "[" + exitCode + "]$ "
|
||||||
|
}
|
||||||
|
return pwd + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellCompleter implements readline.AutoCompleter.
|
||||||
|
// readline's built-in PrefixCompleter matches candidates against the full
|
||||||
|
// line, so "C:\workspace" never matches "cd C:\w". This implementation
|
||||||
|
// extracts the last token and returns only the suffix to append.
|
||||||
|
type shellCompleter struct{ sh *shell.Shell }
|
||||||
|
|
||||||
|
func (c *shellCompleter) Do(line []rune, pos int) (newLine [][]rune, offset int) {
|
||||||
|
lineStr := string(line[:pos])
|
||||||
|
last := lastToken(lineStr)
|
||||||
|
for _, comp := range dynamicComplete(c.sh, lineStr) {
|
||||||
|
if !strings.HasPrefix(comp, last) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suffix := []rune(comp[len(last):])
|
||||||
|
// Add a trailing space for non-directory completions.
|
||||||
|
if len(suffix) > 0 && suffix[len(suffix)-1] != '/' && suffix[len(suffix)-1] != '\\' {
|
||||||
|
suffix = append(suffix, ' ')
|
||||||
|
}
|
||||||
|
newLine = append(newLine, suffix)
|
||||||
|
}
|
||||||
|
return newLine, len([]rune(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastToken returns the last whitespace-delimited token from s,
|
||||||
|
// respecting single and double quotes.
|
||||||
|
func lastToken(s string) string {
|
||||||
|
if strings.HasSuffix(s, " ") || strings.HasSuffix(s, "\t") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
inSingle, inDouble := false, false
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
switch s[i] {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
case ' ', '\t':
|
||||||
|
if !inSingle && !inDouble {
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s[start:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamicComplete provides tab completion for commands and paths.
|
||||||
|
func dynamicComplete(sh *shell.Shell, line string) []string {
|
||||||
|
line = strings.TrimLeft(line, " \t")
|
||||||
|
var completions []string
|
||||||
|
|
||||||
|
// Check if we're completing the first word (command) or an argument (path)
|
||||||
|
words := strings.Fields(line)
|
||||||
|
completingCommand := len(words) == 0 || (len(words) == 1 && !strings.HasSuffix(line, " "))
|
||||||
|
|
||||||
|
if completingCommand {
|
||||||
|
prefix := ""
|
||||||
|
if len(words) == 1 {
|
||||||
|
prefix = words[0]
|
||||||
|
}
|
||||||
|
// Builtin/function names — access via the shell instance
|
||||||
|
// (we can't iterate unexported fields, so use a public method)
|
||||||
|
for _, name := range sh.BuiltinNames() {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
completions = append(completions, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PATH executables
|
||||||
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
// Strip .exe on completion display
|
||||||
|
name = strings.TrimSuffix(name, ".exe")
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
completions = append(completions, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Path completion
|
||||||
|
prefix := ""
|
||||||
|
if len(words) > 0 {
|
||||||
|
prefix = words[len(words)-1]
|
||||||
|
if strings.HasSuffix(line, " ") {
|
||||||
|
prefix = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(prefix)
|
||||||
|
base := filepath.Base(prefix)
|
||||||
|
if prefix == "" || strings.HasSuffix(prefix, "/") || strings.HasSuffix(prefix, "\\") {
|
||||||
|
dir = prefix
|
||||||
|
base = ""
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, base) {
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if e.IsDir() {
|
||||||
|
p += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
completions = append(completions, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterInput(r rune) (rune, bool) {
|
||||||
|
// Block Ctrl+Z (26) — on Windows this would suspend; we handle it gracefully
|
||||||
|
if r == 26 {
|
||||||
|
return r, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/cametendo/bash-for-windows
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
|
||||||
|
)
|
||||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
|
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
|
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||||
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
19
install.bat
Normal file
19
install.bat
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
REM Run the PowerShell installer from the same directory as this .bat file.
|
||||||
|
REM Works even if PowerShell's execution policy is Restricted.
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo bash-for-windows installer
|
||||||
|
echo.
|
||||||
|
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass ^
|
||||||
|
-File "%~dp0install.ps1" %*
|
||||||
|
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo.
|
||||||
|
echo Installation failed. See error above.
|
||||||
|
pause
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
216
install.ps1
Normal file
216
install.ps1
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Installs bash-for-windows and registers it as a Windows Terminal profile.
|
||||||
|
.DESCRIPTION
|
||||||
|
Copies bash.exe to a permanent location, adds it to the user PATH,
|
||||||
|
and adds a "Bash for Windows" entry to Windows Terminal so it appears
|
||||||
|
in the new-tab dropdown.
|
||||||
|
.EXAMPLE
|
||||||
|
.\install.ps1
|
||||||
|
.\install.ps1 -InstallDir "$env:APPDATA\BashForWindows"
|
||||||
|
.\install.ps1 -NoPath -NoTerminal
|
||||||
|
.\install.ps1 -Uninstall
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\BashForWindows",
|
||||||
|
[switch]$NoPath,
|
||||||
|
[switch]$NoTerminal,
|
||||||
|
[switch]$Uninstall
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ExeName = "bash.exe"
|
||||||
|
$ProfileGuid = "{7a86baf2-f5b9-4b78-b3f8-5e1e4f3d9b2a}"
|
||||||
|
$ProfileName = "Bash for Windows"
|
||||||
|
|
||||||
|
# ── Pretty output helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Write-Header {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " bash-for-windows" -ForegroundColor White -NoNewline
|
||||||
|
Write-Host " installer" -ForegroundColor DarkGray
|
||||||
|
Write-Host " ─────────────────────────────────────────" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Step { param([string]$s) Write-Host "`n ▸ $s" -ForegroundColor Cyan }
|
||||||
|
function Write-Ok { param([string]$s) Write-Host " ✓ $s" -ForegroundColor Green }
|
||||||
|
function Write-Warn { param([string]$s) Write-Host " ⚠ $s" -ForegroundColor Yellow }
|
||||||
|
function Write-Fatal { param([string]$s) Write-Host " ✗ $s" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# ── Locate source bash.exe (must live next to this script) ────────────────────
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$SourceExe = Join-Path $ScriptDir $ExeName
|
||||||
|
|
||||||
|
if (-not (Test-Path $SourceExe)) {
|
||||||
|
Write-Header
|
||||||
|
Write-Fatal "'$ExeName' not found next to this script. Expected: $SourceExe"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Windows Terminal settings.json locations ──────────────────────────────────
|
||||||
|
|
||||||
|
function Get-WTSettingsPath {
|
||||||
|
@(
|
||||||
|
"$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json",
|
||||||
|
"$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json",
|
||||||
|
"$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json"
|
||||||
|
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PATH helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Add-ToUserPath {
|
||||||
|
param([string]$Dir)
|
||||||
|
$current = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" |
|
||||||
|
Where-Object { $_ -ne "" }
|
||||||
|
if ($Dir -in $current) {
|
||||||
|
Write-Warn "Already in user PATH: $Dir"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
[Environment]::SetEnvironmentVariable("PATH", (($current + $Dir) -join ";"), "User")
|
||||||
|
Write-Ok "Added to user PATH: $Dir"
|
||||||
|
Write-Warn "Re-open your terminal for the PATH change to take effect."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-FromUserPath {
|
||||||
|
param([string]$Dir)
|
||||||
|
$current = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" |
|
||||||
|
Where-Object { $_ -ne "" -and $_ -ne $Dir }
|
||||||
|
[Environment]::SetEnvironmentVariable("PATH", ($current -join ";"), "User")
|
||||||
|
Write-Ok "Removed from user PATH: $Dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Windows Terminal profile helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function Register-WTProfile {
|
||||||
|
param([string]$ExePath)
|
||||||
|
|
||||||
|
$settingsPath = Get-WTSettingsPath
|
||||||
|
if (-not $settingsPath) {
|
||||||
|
Write-Warn "Windows Terminal not found — skipping profile registration."
|
||||||
|
Write-Warn "Install Windows Terminal from the Microsoft Store and re-run the installer."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$raw = Get-Content $settingsPath -Raw -Encoding UTF8
|
||||||
|
$json = $raw | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
Write-Warn "Could not parse settings.json: $_"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the profile object
|
||||||
|
$newProfile = [PSCustomObject]@{
|
||||||
|
guid = $ProfileGuid
|
||||||
|
name = $ProfileName
|
||||||
|
commandline = $ExePath
|
||||||
|
startingDirectory = "%USERPROFILE%"
|
||||||
|
hidden = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure profiles.list exists
|
||||||
|
if (-not $json.PSObject.Properties["profiles"]) {
|
||||||
|
Add-Member -InputObject $json -MemberType NoteProperty `
|
||||||
|
-Name "profiles" -Value ([PSCustomObject]@{ list = @() })
|
||||||
|
}
|
||||||
|
if (-not $json.profiles.PSObject.Properties["list"]) {
|
||||||
|
Add-Member -InputObject $json.profiles -MemberType NoteProperty `
|
||||||
|
-Name "list" -Value @() -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = @($json.profiles.list)
|
||||||
|
|
||||||
|
# Find existing entry by GUID
|
||||||
|
$existingIdx = -1
|
||||||
|
for ($i = 0; $i -lt $list.Count; $i++) {
|
||||||
|
if ($list[$i].guid -eq $ProfileGuid) { $existingIdx = $i; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingIdx -ge 0) {
|
||||||
|
$list[$existingIdx] = $newProfile
|
||||||
|
Write-Ok "Updated existing '$ProfileName' profile."
|
||||||
|
} else {
|
||||||
|
# Prepend so it appears near the top of the dropdown
|
||||||
|
$list = @($newProfile) + $list
|
||||||
|
Write-Ok "Added '$ProfileName' profile."
|
||||||
|
}
|
||||||
|
|
||||||
|
$json.profiles.list = $list
|
||||||
|
|
||||||
|
# Write back — high depth so nested keybindings etc. aren't truncated
|
||||||
|
$updated = $json | ConvertTo-Json -Depth 50
|
||||||
|
[System.IO.File]::WriteAllText($settingsPath, $updated, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Ok "Saved settings: $settingsPath"
|
||||||
|
Write-Ok "Open Windows Terminal, click '+', and select '$ProfileName'."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-WTProfile {
|
||||||
|
$settingsPath = Get-WTSettingsPath
|
||||||
|
if (-not $settingsPath) { return }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$json = Get-Content $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
} catch { return }
|
||||||
|
|
||||||
|
if (-not $json.profiles -or -not $json.profiles.list) { return }
|
||||||
|
|
||||||
|
$list = @($json.profiles.list) | Where-Object { $_.guid -ne $ProfileGuid }
|
||||||
|
$json.profiles.list = $list
|
||||||
|
$updated = $json | ConvertTo-Json -Depth 50
|
||||||
|
[System.IO.File]::WriteAllText($settingsPath, $updated, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Ok "Removed '$ProfileName' from Windows Terminal."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UNINSTALL
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($Uninstall) {
|
||||||
|
Write-Header
|
||||||
|
Write-Step "Removing files"
|
||||||
|
if (Test-Path $InstallDir) {
|
||||||
|
Remove-Item $InstallDir -Recurse -Force
|
||||||
|
Write-Ok "Deleted: $InstallDir"
|
||||||
|
} else {
|
||||||
|
Write-Warn "Not found (already removed?): $InstallDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Removing from PATH"
|
||||||
|
Remove-FromUserPath $InstallDir
|
||||||
|
|
||||||
|
Write-Step "Removing Windows Terminal profile"
|
||||||
|
Remove-WTProfile
|
||||||
|
|
||||||
|
Write-Host "`n Uninstall complete." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# INSTALL
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Header
|
||||||
|
|
||||||
|
Write-Step "Installing binary"
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
$DestExe = Join-Path $InstallDir $ExeName
|
||||||
|
Copy-Item -Path $SourceExe -Destination $DestExe -Force
|
||||||
|
Write-Ok "Copied to: $DestExe"
|
||||||
|
|
||||||
|
if (-not $NoPath) {
|
||||||
|
Write-Step "Updating user PATH"
|
||||||
|
Add-ToUserPath $InstallDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoTerminal) {
|
||||||
|
Write-Step "Registering Windows Terminal profile"
|
||||||
|
Register-WTProfile $DestExe
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n Install complete." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
2252
internal/shell/builtins.go
Normal file
2252
internal/shell/builtins.go
Normal file
File diff suppressed because it is too large
Load Diff
666
internal/shell/control.go
Normal file
666
internal/shell/control.go
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// executeIf handles: if COND; then BODY; [elif COND; then BODY;]* [else BODY;] fi
|
||||||
|
func (s *Shell) executeIf(block string) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
|
||||||
|
type branch struct {
|
||||||
|
cond []string
|
||||||
|
body []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var branches []branch
|
||||||
|
var elseBody []string
|
||||||
|
|
||||||
|
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" && (phase == "if_cond" || phase == "elif_cond"):
|
||||||
|
if rest != "" {
|
||||||
|
curBody = append(curBody, rest)
|
||||||
|
}
|
||||||
|
phase = "body"
|
||||||
|
case w == "elif":
|
||||||
|
branches = append(branches, branch{curCond, curBody})
|
||||||
|
curCond = nil
|
||||||
|
curBody = nil
|
||||||
|
if rest != "" {
|
||||||
|
curCond = append(curCond, rest)
|
||||||
|
}
|
||||||
|
phase = "elif_cond"
|
||||||
|
case w == "else":
|
||||||
|
branches = append(branches, branch{curCond, curBody})
|
||||||
|
curCond = nil
|
||||||
|
curBody = nil
|
||||||
|
if rest != "" {
|
||||||
|
elseBody = append(elseBody, rest)
|
||||||
|
}
|
||||||
|
phase = "else"
|
||||||
|
case w == "fi":
|
||||||
|
switch phase {
|
||||||
|
case "body":
|
||||||
|
branches = append(branches, branch{curCond, curBody})
|
||||||
|
case "else":
|
||||||
|
if rest != "" {
|
||||||
|
elseBody = append(elseBody, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
switch phase {
|
||||||
|
case "if_cond", "elif_cond":
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range branches {
|
||||||
|
cond := strings.Join(b.cond, "\n")
|
||||||
|
s.Execute(cond) //nolint — we only care about $?
|
||||||
|
if s.vars["?"] == "0" {
|
||||||
|
return s.Execute(strings.Join(b.body, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(elseBody) > 0 {
|
||||||
|
return s.Execute(strings.Join(elseBody, "\n"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeCase handles: case WORD in PAT1) BODY1 ;; PAT2) BODY2 ;; esac
|
||||||
|
// splitStatements emits ";;" as a separate token, so we can parse arms directly.
|
||||||
|
func (s *Shell) executeCase(block string) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// First stmt: "case WORD in"
|
||||||
|
caseHeader := stmts[0]
|
||||||
|
caseRest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(caseHeader), "case"))
|
||||||
|
|
||||||
|
var word string
|
||||||
|
var startIdx int
|
||||||
|
|
||||||
|
// Find trailing " in" to separate word from "in"
|
||||||
|
inIdx := strings.LastIndex(caseRest, " in")
|
||||||
|
if inIdx >= 0 && strings.TrimSpace(caseRest[inIdx+3:]) == "" {
|
||||||
|
word = s.expandWord(strings.TrimSpace(caseRest[:inIdx]))
|
||||||
|
startIdx = 1
|
||||||
|
} else {
|
||||||
|
word = s.expandWord(caseRest)
|
||||||
|
startIdx = 1
|
||||||
|
if startIdx < len(stmts) && strings.TrimSpace(stmts[startIdx]) == "in" {
|
||||||
|
startIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse arms from remaining stmts
|
||||||
|
// stmts now include ";;" as explicit tokens
|
||||||
|
type arm struct {
|
||||||
|
patterns []string
|
||||||
|
body []string
|
||||||
|
}
|
||||||
|
var arms []arm
|
||||||
|
var curArm *arm
|
||||||
|
|
||||||
|
for _, stmt := range stmts[startIdx:] {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stmt == "esac" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if stmt == ";;" || stmt == ";&" || stmt == ";;&" {
|
||||||
|
curArm = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if curArm == nil {
|
||||||
|
// Expect a pattern: PAT) [body]
|
||||||
|
parenIdx := findCasePatternEnd(stmt)
|
||||||
|
if parenIdx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patStr := strings.TrimSpace(stmt[:parenIdx])
|
||||||
|
bodyStr := strings.TrimSpace(stmt[parenIdx+1:])
|
||||||
|
|
||||||
|
rawPats := strings.Split(patStr, "|")
|
||||||
|
var pats []string
|
||||||
|
for _, p := range rawPats {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
pats = append(pats, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arms = append(arms, arm{patterns: pats})
|
||||||
|
curArm = &arms[len(arms)-1]
|
||||||
|
if bodyStr != "" {
|
||||||
|
curArm.body = append(curArm.body, bodyStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
curArm.body = append(curArm.body, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute matching arm
|
||||||
|
for _, a := range arms {
|
||||||
|
for _, pat := range a.patterns {
|
||||||
|
expandedPat := s.expandWord(pat)
|
||||||
|
matched := false
|
||||||
|
if expandedPat == "*" {
|
||||||
|
matched = true
|
||||||
|
} else {
|
||||||
|
if m, err := filepath.Match(expandedPat, word); err == nil {
|
||||||
|
matched = m
|
||||||
|
} else {
|
||||||
|
matched = expandedPat == word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
body := strings.Join(a.body, "\n")
|
||||||
|
return s.Execute(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCasePatternEnd finds the ) that ends the pattern in a case arm.
|
||||||
|
// Handles quoted strings.
|
||||||
|
func findCasePatternEnd(chunk string) int {
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
for i := 0; i < len(chunk); i++ {
|
||||||
|
c := chunk[i]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble:
|
||||||
|
inSingle = !inSingle
|
||||||
|
case c == '"' && !inSingle:
|
||||||
|
inDouble = !inDouble
|
||||||
|
case c == ')' && !inSingle && !inDouble:
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeFor handles: for VAR in WORDS; do BODY; done
|
||||||
|
// (also: for VAR; do BODY; done — iterates positional params)
|
||||||
|
func (s *Shell) executeFor(block string) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "for VAR in WORDS"
|
||||||
|
header := stmts[0]
|
||||||
|
// Use tokenize so array expansion works in "for x in ${arr[@]}"
|
||||||
|
fields := s.tokenize(header)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return fmt.Errorf("for: bad syntax")
|
||||||
|
}
|
||||||
|
varName := fields[1]
|
||||||
|
|
||||||
|
var items []string
|
||||||
|
inIdx := -1
|
||||||
|
for i, w := range fields {
|
||||||
|
if w == "in" {
|
||||||
|
inIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inIdx >= 0 {
|
||||||
|
// Items are already expanded by tokenize
|
||||||
|
items = fields[inIdx+1:]
|
||||||
|
} else {
|
||||||
|
// for var; do ... → iterate positional params
|
||||||
|
items = s.args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect body between "do" and "done"
|
||||||
|
var bodyStmts []string
|
||||||
|
inBody := false
|
||||||
|
for _, stmt := range stmts[1:] {
|
||||||
|
w := firstWord(stmt)
|
||||||
|
if !inBody {
|
||||||
|
if w == "do" {
|
||||||
|
inBody = true
|
||||||
|
if rest := afterWord(stmt); rest != "" {
|
||||||
|
bodyStmts = append(bodyStmts, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if w == "done" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bodyStmts = append(bodyStmts, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.Join(bodyStmts, "\n")
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
s.vars[varName] = item
|
||||||
|
if err := s.Execute(body); err != nil {
|
||||||
|
if be, ok := err.(breakErr); ok {
|
||||||
|
if be.n <= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return breakErr{be.n - 1}
|
||||||
|
}
|
||||||
|
if ce, ok := err.(continueErr); ok {
|
||||||
|
if ce.n <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return continueErr{ce.n - 1}
|
||||||
|
}
|
||||||
|
if _, ok := err.(returnErr); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeForC handles C-style: for ((init; cond; incr)); do BODY; done
|
||||||
|
func (s *Shell) executeForC(block string) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the ((...)) header
|
||||||
|
header := stmts[0]
|
||||||
|
// Find "for" keyword and then "(("
|
||||||
|
trimmed := strings.TrimSpace(header)
|
||||||
|
// Strip "for" keyword
|
||||||
|
trimmed = strings.TrimSpace(trimmed[3:]) // skip "for"
|
||||||
|
// Expect "(("
|
||||||
|
if !strings.HasPrefix(trimmed, "((") {
|
||||||
|
return fmt.Errorf("for: bad C-style syntax")
|
||||||
|
}
|
||||||
|
trimmed = trimmed[2:] // skip "(("
|
||||||
|
// Find closing "))"
|
||||||
|
endIdx := strings.Index(trimmed, "))")
|
||||||
|
if endIdx < 0 {
|
||||||
|
return fmt.Errorf("for: missing '))'")
|
||||||
|
}
|
||||||
|
inner := trimmed[:endIdx]
|
||||||
|
|
||||||
|
// Split on ; to get init, cond, incr
|
||||||
|
parts := strings.SplitN(inner, ";", 3)
|
||||||
|
init := ""
|
||||||
|
cond := ""
|
||||||
|
incr := ""
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
init = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
cond = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
incr = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute init as arithmetic assignment
|
||||||
|
if init != "" {
|
||||||
|
s.execArithAssign(init)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect body between "do" and "done"
|
||||||
|
var bodyStmts []string
|
||||||
|
inBody := false
|
||||||
|
for _, stmt := range stmts[1:] {
|
||||||
|
w := firstWord(stmt)
|
||||||
|
if !inBody {
|
||||||
|
if w == "do" {
|
||||||
|
inBody = true
|
||||||
|
if rest := afterWord(stmt); rest != "" {
|
||||||
|
bodyStmts = append(bodyStmts, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if w == "done" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bodyStmts = append(bodyStmts, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.Join(bodyStmts, "\n")
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Evaluate condition
|
||||||
|
if cond != "" {
|
||||||
|
if s.evalArith(cond) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Execute(body); err != nil {
|
||||||
|
if be, ok := err.(breakErr); ok {
|
||||||
|
if be.n <= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return breakErr{be.n - 1}
|
||||||
|
}
|
||||||
|
if ce, ok := err.(continueErr); ok {
|
||||||
|
if ce.n <= 1 {
|
||||||
|
// continue — execute incr then re-check cond
|
||||||
|
if incr != "" {
|
||||||
|
s.execArithAssign(incr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return continueErr{ce.n - 1}
|
||||||
|
}
|
||||||
|
if _, ok := err.(returnErr); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute increment
|
||||||
|
if incr != "" {
|
||||||
|
s.execArithAssign(incr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execArithAssign handles arithmetic assignment expressions like i=0, i++, i+=1, ((i++))
|
||||||
|
func (s *Shell) execArithAssign(expr string) {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
if expr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle i++ and i--
|
||||||
|
if strings.HasSuffix(expr, "++") {
|
||||||
|
varName := strings.TrimSpace(expr[:len(expr)-2])
|
||||||
|
if isValidIdentifier(varName) {
|
||||||
|
n := s.evalArith(varName)
|
||||||
|
s.vars[varName] = fmt.Sprintf("%d", n+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(expr, "--") {
|
||||||
|
varName := strings.TrimSpace(expr[:len(expr)-2])
|
||||||
|
if isValidIdentifier(varName) {
|
||||||
|
n := s.evalArith(varName)
|
||||||
|
s.vars[varName] = fmt.Sprintf("%d", n-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle i+=N
|
||||||
|
if idx := strings.Index(expr, "+="); idx > 0 {
|
||||||
|
varName := strings.TrimSpace(expr[:idx])
|
||||||
|
if isValidIdentifier(varName) {
|
||||||
|
delta := s.evalArith(expr[idx+2:])
|
||||||
|
n := s.evalArith(varName)
|
||||||
|
s.vars[varName] = fmt.Sprintf("%d", n+delta)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle i-=N
|
||||||
|
if idx := strings.Index(expr, "-="); idx > 0 {
|
||||||
|
varName := strings.TrimSpace(expr[:idx])
|
||||||
|
if isValidIdentifier(varName) {
|
||||||
|
delta := s.evalArith(expr[idx+2:])
|
||||||
|
n := s.evalArith(varName)
|
||||||
|
s.vars[varName] = fmt.Sprintf("%d", n-delta)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle i=expr
|
||||||
|
if idx := strings.Index(expr, "="); idx > 0 {
|
||||||
|
varName := strings.TrimSpace(expr[:idx])
|
||||||
|
if isValidIdentifier(varName) {
|
||||||
|
n := s.evalArith(expr[idx+1:])
|
||||||
|
s.vars[varName] = fmt.Sprintf("%d", n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeWhileUntil handles while/until loops.
|
||||||
|
func (s *Shell) executeWhileUntil(block string, isUntil bool) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse condition (everything from "while/until COND" up to "do")
|
||||||
|
var condStmts []string
|
||||||
|
if rest := afterWord(stmts[0]); rest != "" {
|
||||||
|
condStmts = append(condStmts, rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyStmts []string
|
||||||
|
inBody := false
|
||||||
|
for _, stmt := range stmts[1:] {
|
||||||
|
w := firstWord(stmt)
|
||||||
|
if !inBody {
|
||||||
|
if w == "do" {
|
||||||
|
inBody = true
|
||||||
|
if rest := afterWord(stmt); rest != "" {
|
||||||
|
bodyStmts = append(bodyStmts, rest)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
condStmts = append(condStmts, stmt)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if w == "done" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bodyStmts = append(bodyStmts, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := strings.Join(condStmts, "\n")
|
||||||
|
body := strings.Join(bodyStmts, "\n")
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.Execute(cond) //nolint
|
||||||
|
condOk := s.vars["?"] == "0"
|
||||||
|
|
||||||
|
if (isUntil && condOk) || (!isUntil && !condOk) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Execute(body); err != nil {
|
||||||
|
if be, ok := err.(breakErr); ok {
|
||||||
|
if be.n <= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return breakErr{be.n - 1}
|
||||||
|
}
|
||||||
|
if ce, ok := err.(continueErr); ok {
|
||||||
|
if ce.n <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return continueErr{ce.n - 1}
|
||||||
|
}
|
||||||
|
if _, ok := err.(returnErr); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defineFunction parses and registers a shell function definition.
|
||||||
|
func (s *Shell) defineFunction(block string) error {
|
||||||
|
stmts := splitStatements(block)
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
return fmt.Errorf("syntax error: empty function")
|
||||||
|
}
|
||||||
|
|
||||||
|
first := stmts[0]
|
||||||
|
var name string
|
||||||
|
|
||||||
|
if strings.HasPrefix(first, "function ") {
|
||||||
|
rest := strings.TrimPrefix(first, "function ")
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
// Strip trailing () and {
|
||||||
|
rest = strings.TrimSuffix(strings.TrimSpace(rest), "{")
|
||||||
|
rest = strings.TrimSuffix(strings.TrimSpace(rest), "()")
|
||||||
|
name = strings.TrimSpace(rest)
|
||||||
|
} else {
|
||||||
|
parenIdx := strings.Index(first, "(")
|
||||||
|
if parenIdx < 0 {
|
||||||
|
return fmt.Errorf("syntax error: bad function definition")
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(first[:parenIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidIdentifier(name) {
|
||||||
|
return fmt.Errorf("syntax error: invalid function name %q", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the opening { in the block — it may be on the same line as the name
|
||||||
|
// or on a following stmt. Everything after { (up to closing }) is the body.
|
||||||
|
var bodyStmts []string
|
||||||
|
inBody := false
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
trimmed := strings.TrimSpace(stmt)
|
||||||
|
|
||||||
|
if !inBody {
|
||||||
|
// Look for { in this stmt
|
||||||
|
braceIdx := strings.Index(trimmed, "{")
|
||||||
|
if braceIdx >= 0 {
|
||||||
|
inBody = true
|
||||||
|
rest := strings.TrimSpace(trimmed[braceIdx+1:])
|
||||||
|
if rest != "" && rest != "}" {
|
||||||
|
bodyStmts = append(bodyStmts, rest)
|
||||||
|
}
|
||||||
|
// Check if } is also on this line (single-liner like name() { cmd; })
|
||||||
|
if strings.HasSuffix(trimmed, "}") && braceIdx < len(trimmed)-1 {
|
||||||
|
// body is between { and }
|
||||||
|
inner := strings.TrimSpace(trimmed[braceIdx+1 : len(trimmed)-1])
|
||||||
|
bodyStmts = nil
|
||||||
|
if inner != "" {
|
||||||
|
bodyStmts = append(bodyStmts, inner)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed == "}" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bodyStmts = append(bodyStmts, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcBody := strings.Join(bodyStmts, "\n")
|
||||||
|
s.funcs[name] = funcBody
|
||||||
|
|
||||||
|
funcName := name
|
||||||
|
s.builtins[funcName] = func(args []string) error {
|
||||||
|
return s.callFunction(funcName, args)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) callFunction(name string, args []string) error {
|
||||||
|
body, ok := s.funcs[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s: function not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save positional params and exit code
|
||||||
|
oldArgs := s.args
|
||||||
|
savedPos := map[string]string{}
|
||||||
|
for k, v := range s.vars {
|
||||||
|
if k == "#" || k == "@" || k == "*" || (len(k) == 1 && k[0] >= '1' && k[0] <= '9') {
|
||||||
|
savedPos[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SetArgs(args)
|
||||||
|
s.vars["?"] = "0" // reset before running body
|
||||||
|
|
||||||
|
err := s.Execute(body)
|
||||||
|
|
||||||
|
// Capture the function's exit code BEFORE restoring params (which might not include ?)
|
||||||
|
funcExitCode := s.lastExit
|
||||||
|
|
||||||
|
// Restore positional params
|
||||||
|
s.args = oldArgs
|
||||||
|
for k, v := range savedPos {
|
||||||
|
s.vars[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if re, ok := err.(returnErr); ok {
|
||||||
|
if re.code != 0 {
|
||||||
|
return exitCodeErr{re.code}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Propagate last command's exit code from the function body
|
||||||
|
if funcExitCode != 0 {
|
||||||
|
return exitCodeErr{funcExitCode}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
524
internal/shell/exec.go
Normal file
524
internal/shell/exec.go
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redirect struct {
|
||||||
|
fd int // 0=stdin 1=stdout 2=stderr -1=both stdout+stderr
|
||||||
|
mode string // ">" ">>" "<"
|
||||||
|
dest string // filename or "&1" "&2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// executePipeline handles background jobs (&) and pipelines (|).
|
||||||
|
func (s *Shell) executePipeline(input string) error {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background job: trailing & (not &&)
|
||||||
|
bg := false
|
||||||
|
if strings.HasSuffix(input, "&") && !strings.HasSuffix(input, "&&") {
|
||||||
|
bg = true
|
||||||
|
input = strings.TrimSuffix(strings.TrimSuffix(input, "&"), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := splitPipe(input)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
if bg {
|
||||||
|
return s.executeCommandBg(strings.TrimSpace(parts[0]))
|
||||||
|
}
|
||||||
|
return s.executeCommand(strings.TrimSpace(parts[0]))
|
||||||
|
}
|
||||||
|
return s.doPipe(parts, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPipe splits by | but not ||, respecting quotes.
|
||||||
|
func splitPipe(input string) []string {
|
||||||
|
var parts []string
|
||||||
|
current := strings.Builder{}
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
parenDepth := 0
|
||||||
|
pendingDollar := false
|
||||||
|
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
c := input[i]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble && parenDepth == 0:
|
||||||
|
inSingle = !inSingle
|
||||||
|
current.WriteByte(c)
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
if i+1 < len(input) && input[i+1] == '|' {
|
||||||
|
// || operator — pass both chars through
|
||||||
|
current.WriteByte(c)
|
||||||
|
current.WriteByte(input[i+1])
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
parts = append(parts, strings.TrimSpace(current.String()))
|
||||||
|
current.Reset()
|
||||||
|
pendingDollar = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
pendingDollar = false
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
parts = append(parts, strings.TrimSpace(current.String()))
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArrayAssign detects NAME=(...) or NAME+=(...) at start of input.
|
||||||
|
func (s *Shell) parseArrayAssign(input string) (name string, appendMode bool, elements []string, ok bool) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
// Read identifier
|
||||||
|
i := 0
|
||||||
|
for i < len(input) && isVarChar(input[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name = input[:i]
|
||||||
|
if !isValidIdentifier(name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Optional +
|
||||||
|
if i < len(input) && input[i] == '+' {
|
||||||
|
appendMode = true
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Require =
|
||||||
|
if i >= len(input) || input[i] != '=' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
// Require (
|
||||||
|
if i >= len(input) || input[i] != '(' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
// Find matching )
|
||||||
|
depth := 1
|
||||||
|
j := i
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
for j < len(input) && depth > 0 {
|
||||||
|
c := input[j]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble:
|
||||||
|
inSingle = !inSingle
|
||||||
|
case c == '"' && !inSingle:
|
||||||
|
inDouble = !inDouble
|
||||||
|
case c == '(' && !inSingle && !inDouble:
|
||||||
|
depth++
|
||||||
|
case c == ')' && !inSingle && !inDouble:
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := input[i:j]
|
||||||
|
elements = s.tokenize(content)
|
||||||
|
ok = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeCommand executes a single command (no pipes, no &&/||).
|
||||||
|
func (s *Shell) executeCommand(input string) error {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
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 {
|
||||||
|
s.appendArray(name, elems)
|
||||||
|
} else {
|
||||||
|
s.setArray(name, elems)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := s.tokenize(input)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs, redirects := s.extractRedirects(tokens)
|
||||||
|
if len(cmdArgs) == 0 {
|
||||||
|
// Pure redirection, e.g. "> file" creates/truncates file
|
||||||
|
return s.withRedirects(redirects, func() error { return nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdName := cmdArgs[0]
|
||||||
|
args := cmdArgs[1:]
|
||||||
|
|
||||||
|
// Alias expansion
|
||||||
|
if alias, ok := aliases[cmdName]; ok {
|
||||||
|
full := alias
|
||||||
|
if len(args) > 0 {
|
||||||
|
full += " " + strings.Join(args, " ")
|
||||||
|
}
|
||||||
|
return s.withRedirects(redirects, func() error {
|
||||||
|
return s.Execute(full)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtin
|
||||||
|
if builtin, ok := s.builtins[cmdName]; ok {
|
||||||
|
return s.withRedirects(redirects, func() error {
|
||||||
|
return builtin(args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// External
|
||||||
|
return s.withRedirects(redirects, func() error {
|
||||||
|
return s.executeExternal(cmdName, args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) extractRedirects(tokens []string) ([]string, []redirect) {
|
||||||
|
var args []string
|
||||||
|
var redirects []redirect
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(tokens) {
|
||||||
|
tok := tokens[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// 2>&1
|
||||||
|
case tok == "2>&1":
|
||||||
|
redirects = append(redirects, redirect{2, ">", "&1"})
|
||||||
|
i++
|
||||||
|
// 1>&2
|
||||||
|
case tok == "1>&2":
|
||||||
|
redirects = append(redirects, redirect{1, ">", "&2"})
|
||||||
|
i++
|
||||||
|
// &> or &>> (both stdout+stderr)
|
||||||
|
case tok == "&>" || tok == "&>>":
|
||||||
|
if i+1 < len(tokens) {
|
||||||
|
redirects = append(redirects, redirect{-1, strings.TrimPrefix(tok, "&"), tokens[i+1]})
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "&>"):
|
||||||
|
mode := ">"
|
||||||
|
dest := tok[2:]
|
||||||
|
if strings.HasPrefix(dest, ">") {
|
||||||
|
mode = ">>"
|
||||||
|
dest = dest[1:]
|
||||||
|
}
|
||||||
|
redirects = append(redirects, redirect{-1, mode, dest})
|
||||||
|
i++
|
||||||
|
// 2> 2>> 2>file
|
||||||
|
case tok == "2>" || tok == "2>>":
|
||||||
|
if i+1 < len(tokens) {
|
||||||
|
redirects = append(redirects, redirect{2, tok[1:], tokens[i+1]})
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "2>>"):
|
||||||
|
redirects = append(redirects, redirect{2, ">>", tok[3:]})
|
||||||
|
i++
|
||||||
|
case strings.HasPrefix(tok, "2>"):
|
||||||
|
redirects = append(redirects, redirect{2, ">", tok[2:]})
|
||||||
|
i++
|
||||||
|
// > >>
|
||||||
|
case tok == ">" || tok == ">>":
|
||||||
|
if i+1 < len(tokens) {
|
||||||
|
redirects = append(redirects, redirect{1, tok, tokens[i+1]})
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, ">>") && len(tok) > 2:
|
||||||
|
redirects = append(redirects, redirect{1, ">>", tok[2:]})
|
||||||
|
i++
|
||||||
|
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) {
|
||||||
|
next := tokens[i+1]
|
||||||
|
// Process substitution: < <(cmd)
|
||||||
|
if strings.HasPrefix(next, "<(") && strings.HasSuffix(next, ")") {
|
||||||
|
cmd := next[2 : len(next)-1]
|
||||||
|
tmpf, err := os.CreateTemp("", "procsub*")
|
||||||
|
if err == nil {
|
||||||
|
s.withIO(nil, tmpf, nil, func() error { return s.Execute(cmd) })
|
||||||
|
tmpf.Close()
|
||||||
|
redirects = append(redirects, redirect{0, "<", tmpf.Name()})
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
redirects = append(redirects, redirect{0, "<", next})
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "<") && len(tok) > 1 && tok[1] != '<':
|
||||||
|
redirects = append(redirects, redirect{0, "<", tok[1:]})
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
args = append(args, tok)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) withRedirects(redirects []redirect, fn func() error) error {
|
||||||
|
if len(redirects) == 0 {
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
oldIn, oldOut, oldErr := s.Stdin, s.Stdout, s.Stderr
|
||||||
|
var toClose []io.Closer
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
s.Stdin, s.Stdout, s.Stderr = oldIn, oldOut, oldErr
|
||||||
|
for _, c := range toClose {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
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" {
|
||||||
|
s.Stderr = s.Stdout
|
||||||
|
} else if r.dest == "&2" {
|
||||||
|
s.Stdout = s.Stderr
|
||||||
|
} else {
|
||||||
|
f, err := os.Create(r.dest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open %s: %v", r.dest, err)
|
||||||
|
}
|
||||||
|
toClose = append(toClose, f)
|
||||||
|
if r.fd == 1 || r.fd == -1 {
|
||||||
|
s.Stdout = f
|
||||||
|
}
|
||||||
|
if r.fd == 2 || r.fd == -1 {
|
||||||
|
s.Stderr = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ">>":
|
||||||
|
f, err := os.OpenFile(r.dest, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open %s: %v", r.dest, err)
|
||||||
|
}
|
||||||
|
toClose = append(toClose, f)
|
||||||
|
if r.fd == 1 || r.fd == -1 {
|
||||||
|
s.Stdout = f
|
||||||
|
}
|
||||||
|
if r.fd == 2 || r.fd == -1 {
|
||||||
|
s.Stderr = f
|
||||||
|
}
|
||||||
|
case "<":
|
||||||
|
f, err := os.Open(r.dest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open %s: %v", r.dest, err)
|
||||||
|
}
|
||||||
|
toClose = append(toClose, f)
|
||||||
|
s.Stdin = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) executeExternal(cmdName string, args []string) error {
|
||||||
|
path := findExecutable(cmdName)
|
||||||
|
if path == "" {
|
||||||
|
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
|
||||||
|
cmd.Stderr = s.Stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return exitCodeErr{exitErr.ExitCode()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmdArgs, _ := s.extractRedirects(tokens)
|
||||||
|
if len(cmdArgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := findExecutable(cmdArgs[0])
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("%s: command not found", cmdArgs[0])
|
||||||
|
}
|
||||||
|
cmd := exec.Command(path, cmdArgs[1:]...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
s.vars["!"] = fmt.Sprintf("%d", pid)
|
||||||
|
fmt.Fprintf(s.Stderr, "[1] %d\n", pid)
|
||||||
|
go func() { cmd.Wait() }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findExecutable(name string) string {
|
||||||
|
// Direct path
|
||||||
|
if strings.ContainsAny(name, "/\\") {
|
||||||
|
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
||||||
|
abs, _ := filepath.Abs(name)
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path := os.Getenv("PATH")
|
||||||
|
for _, dir := range filepath.SplitList(path) {
|
||||||
|
for _, candidate := range []string{
|
||||||
|
filepath.Join(dir, name),
|
||||||
|
filepath.Join(dir, name+".exe"),
|
||||||
|
filepath.Join(dir, name+".cmd"),
|
||||||
|
filepath.Join(dir, name+".bat"),
|
||||||
|
} {
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// doPipe executes a pipeline where each stage feeds into the next.
|
||||||
|
func (s *Shell) doPipe(commands []string, bg bool) error {
|
||||||
|
_ = bg // background pipe support would require goroutines; skip for now
|
||||||
|
|
||||||
|
var prevBuf []byte
|
||||||
|
|
||||||
|
for i, cmd := range commands {
|
||||||
|
isLast := i == len(commands)-1
|
||||||
|
var stdinReader io.Reader
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
stdinReader = s.Stdin
|
||||||
|
} else {
|
||||||
|
stdinReader = bytes.NewReader(prevBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLast {
|
||||||
|
return s.withIO(stdinReader, nil, nil, func() error {
|
||||||
|
return s.executeCommand(cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s.withIO(stdinReader, &buf, nil, func() error {
|
||||||
|
return s.executeCommand(cmd)
|
||||||
|
})
|
||||||
|
prevBuf = buf.Bytes()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
742
internal/shell/expand.go
Normal file
742
internal/shell/expand.go
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isVarChar(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandWord expands $VAR, ${VAR}, $(...), $((...)) in a single token.
|
||||||
|
// Quote characters (single/double) are interpreted here and stripped from output.
|
||||||
|
func (s *Shell) expandWord(word string) string {
|
||||||
|
// Tilde expansion (only when not quoted)
|
||||||
|
if strings.HasPrefix(word, "~") {
|
||||||
|
home := s.GetVar("HOME")
|
||||||
|
if home == "" {
|
||||||
|
home = os.Getenv("USERPROFILE")
|
||||||
|
}
|
||||||
|
if len(word) == 1 {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if word[1] == '/' || word[1] == '\\' {
|
||||||
|
return home + word[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(word) {
|
||||||
|
ch := word[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case ch == '\'' && !inDouble:
|
||||||
|
inSingle = !inSingle
|
||||||
|
i++
|
||||||
|
case ch == '"' && !inSingle:
|
||||||
|
inDouble = !inDouble
|
||||||
|
i++
|
||||||
|
case ch == '\\' && !inSingle:
|
||||||
|
if i+1 < len(word) {
|
||||||
|
next := word[i+1]
|
||||||
|
if inDouble {
|
||||||
|
// In double quotes, only certain chars are escaped
|
||||||
|
switch next {
|
||||||
|
case '$', '`', '"', '\\', '\n':
|
||||||
|
result.WriteByte(next)
|
||||||
|
i += 2
|
||||||
|
default:
|
||||||
|
result.WriteByte('\\')
|
||||||
|
result.WriteByte(next)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only consume the backslash when escaping a shell
|
||||||
|
// metacharacter. Before regular path characters (letters,
|
||||||
|
// digits, etc.) keep it literal so Windows paths like
|
||||||
|
// C:\workspace work unquoted.
|
||||||
|
const metachars = " \t\n$*?[\"'\\|&;()<>{}!#~`"
|
||||||
|
if strings.ContainsRune(metachars, rune(next)) {
|
||||||
|
result.WriteByte(next)
|
||||||
|
} else {
|
||||||
|
result.WriteByte('\\')
|
||||||
|
result.WriteByte(next)
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case ch == '$' && !inSingle:
|
||||||
|
i++ // skip $
|
||||||
|
if i >= len(word) {
|
||||||
|
result.WriteByte('$')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// $'...' ANSI C string
|
||||||
|
if word[i] == '\'' {
|
||||||
|
i++ // skip opening '
|
||||||
|
for i < len(word) && word[i] != '\'' {
|
||||||
|
if word[i] == '\\' && i+1 < len(word) {
|
||||||
|
i++ // skip backslash, now at escape char
|
||||||
|
switch word[i] {
|
||||||
|
case 'n':
|
||||||
|
result.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
result.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
result.WriteByte('\r')
|
||||||
|
case '\\':
|
||||||
|
result.WriteByte('\\')
|
||||||
|
case '\'':
|
||||||
|
result.WriteByte('\'')
|
||||||
|
case '"':
|
||||||
|
result.WriteByte('"')
|
||||||
|
case 'a':
|
||||||
|
result.WriteByte('\a')
|
||||||
|
case 'b':
|
||||||
|
result.WriteByte('\b')
|
||||||
|
case 'f':
|
||||||
|
result.WriteByte('\f')
|
||||||
|
case 'v':
|
||||||
|
result.WriteByte('\v')
|
||||||
|
case 'e', 'E':
|
||||||
|
result.WriteByte(0x1b)
|
||||||
|
case '0', '1', '2', '3', '4', '5', '6', '7':
|
||||||
|
// Octal \NNN — up to 3 digits
|
||||||
|
oct := 0
|
||||||
|
for k := 0; k < 3 && i < len(word) && word[i] >= '0' && word[i] <= '7'; k++ {
|
||||||
|
oct = oct*8 + int(word[i]-'0')
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
result.WriteByte(byte(oct))
|
||||||
|
continue
|
||||||
|
case 'x':
|
||||||
|
// Hex \xNN — up to 2 digits
|
||||||
|
i++ // skip 'x'
|
||||||
|
hexv := 0
|
||||||
|
for k := 0; k < 2 && i < len(word); k++ {
|
||||||
|
d := word[i]
|
||||||
|
if d >= '0' && d <= '9' {
|
||||||
|
hexv = hexv*16 + int(d-'0')
|
||||||
|
i++
|
||||||
|
} else if d >= 'a' && d <= 'f' {
|
||||||
|
hexv = hexv*16 + int(d-'a'+10)
|
||||||
|
i++
|
||||||
|
} else if d >= 'A' && d <= 'F' {
|
||||||
|
hexv = hexv*16 + int(d-'A'+10)
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteByte(byte(hexv))
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
result.WriteByte('\\')
|
||||||
|
result.WriteByte(word[i])
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
result.WriteByte(word[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < len(word) {
|
||||||
|
i++ // skip closing '
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch word[i] {
|
||||||
|
case '(':
|
||||||
|
if i+1 < len(word) && word[i+1] == '(' {
|
||||||
|
// $(( arithmetic ))
|
||||||
|
j := i + 2
|
||||||
|
depth := 2
|
||||||
|
for j < len(word) {
|
||||||
|
if word[j] == '(' {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
if word[j] == ')' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
j++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
expr := word[i+2 : j-2]
|
||||||
|
result.WriteString(strconv.Itoa(s.evalArith(expr)))
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// $( command substitution )
|
||||||
|
j := i + 1
|
||||||
|
depth := 1
|
||||||
|
for j < len(word) {
|
||||||
|
if word[j] == '(' {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
if word[j] == ')' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
cmd := word[i+1 : j]
|
||||||
|
out := s.captureCommand(cmd)
|
||||||
|
result.WriteString(strings.TrimRight(out, "\n"))
|
||||||
|
i = j + 1
|
||||||
|
}
|
||||||
|
case '{':
|
||||||
|
j := i + 1
|
||||||
|
depth := 1
|
||||||
|
for j < len(word) {
|
||||||
|
if word[j] == '{' {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
if word[j] == '}' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
varExpr := word[i+1 : j]
|
||||||
|
result.WriteString(s.evalVarExpr(varExpr))
|
||||||
|
i = j + 1
|
||||||
|
case '?':
|
||||||
|
result.WriteString(s.vars["?"])
|
||||||
|
i++
|
||||||
|
case '$':
|
||||||
|
result.WriteString(fmt.Sprintf("%d", os.Getpid()))
|
||||||
|
i++
|
||||||
|
case '!':
|
||||||
|
result.WriteString(s.vars["!"])
|
||||||
|
i++
|
||||||
|
case '#':
|
||||||
|
result.WriteString(s.vars["#"])
|
||||||
|
i++
|
||||||
|
case '@':
|
||||||
|
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["*"])
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
j := i
|
||||||
|
for j < len(word) && isVarChar(word[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j == i {
|
||||||
|
result.WriteByte('$')
|
||||||
|
} else {
|
||||||
|
result.WriteString(s.getVar(word[i:j]))
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) getVar(name string) string {
|
||||||
|
// Resolve nameref chain
|
||||||
|
resolved := s.resolveNR(name)
|
||||||
|
if v, ok := s.vars[resolved]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return os.Getenv(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) evalVarExpr(expr string) string {
|
||||||
|
// ${#arr[@]} or ${#arr[*]} → length of array
|
||||||
|
if strings.HasPrefix(expr, "#") {
|
||||||
|
rest := expr[1:]
|
||||||
|
if strings.HasSuffix(rest, "[@]") || strings.HasSuffix(rest, "[*]") {
|
||||||
|
arrName := rest[:len(rest)-3]
|
||||||
|
return strconv.Itoa(len(s.getArray(arrName)))
|
||||||
|
}
|
||||||
|
// ${#VAR} — string length
|
||||||
|
return strconv.Itoa(len(s.getVar(rest)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ${VAR:-default}
|
||||||
|
if idx := strings.Index(expr, ":-"); idx >= 0 {
|
||||||
|
varName := expr[:idx]
|
||||||
|
if v := s.getVar(varName); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return s.expandWord(expr[idx+2:])
|
||||||
|
}
|
||||||
|
// ${VAR:=default}
|
||||||
|
if idx := strings.Index(expr, ":="); idx >= 0 {
|
||||||
|
varName := expr[:idx]
|
||||||
|
if v := s.getVar(varName); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
expanded := s.expandWord(expr[idx+2:])
|
||||||
|
s.vars[varName] = expanded
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
// ${VAR:+alt}
|
||||||
|
if idx := strings.Index(expr, ":+"); idx >= 0 {
|
||||||
|
varName := expr[:idx]
|
||||||
|
if v := s.getVar(varName); v != "" {
|
||||||
|
return s.expandWord(expr[idx+2:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// ${VAR%pattern} — strip shortest suffix
|
||||||
|
if idx := strings.Index(expr, "%"); idx >= 0 {
|
||||||
|
varName := expr[:idx]
|
||||||
|
pattern := expr[idx+1:]
|
||||||
|
v := s.getVar(varName)
|
||||||
|
if strings.HasSuffix(v, pattern) {
|
||||||
|
return v[:len(v)-len(pattern)]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// ${VAR#pattern} — strip shortest prefix
|
||||||
|
if idx := strings.Index(expr, "#"); idx >= 0 {
|
||||||
|
varName := expr[:idx]
|
||||||
|
pattern := expr[idx+1:]
|
||||||
|
v := s.getVar(varName)
|
||||||
|
if strings.HasPrefix(v, pattern) {
|
||||||
|
return v[len(pattern):]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return s.getVar(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureCommand runs a command and returns its stdout as a string.
|
||||||
|
func (s *Shell) captureCommand(cmd string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s.withIO(nil, &buf, nil, func() error {
|
||||||
|
return s.Execute(cmd)
|
||||||
|
})
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalArith evaluates a shell arithmetic expression.
|
||||||
|
func (s *Shell) evalArith(expr string) int {
|
||||||
|
expr = strings.TrimSpace(s.expandWord(expr))
|
||||||
|
// Expand bare variable names (e.g. i+1 → value_of_i + 1)
|
||||||
|
expr = s.expandArithVars(expr)
|
||||||
|
return evalArithExpr(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandArithVars replaces bare identifier names with their shell variable values.
|
||||||
|
func (s *Shell) expandArithVars(expr string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
i := 0
|
||||||
|
for i < len(expr) {
|
||||||
|
c := expr[i]
|
||||||
|
// Identifier start (letter or _), but not a digit
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
|
||||||
|
j := i
|
||||||
|
for j < len(expr) && isVarChar(expr[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
varName := expr[i:j]
|
||||||
|
val := s.getVar(varName)
|
||||||
|
if val == "" {
|
||||||
|
val = "0"
|
||||||
|
}
|
||||||
|
result.WriteString(val)
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
result.WriteByte(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalArithExpr(expr string) int {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
if n, err := strconv.Atoi(expr); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// Strip outer parens
|
||||||
|
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
|
||||||
|
return evalArithExpr(expr[1 : len(expr)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparison operators (lowest precedence) — multi-char first
|
||||||
|
for _, op := range []string{"<=", ">=", "==", "!=", "<", ">"} {
|
||||||
|
if idx := findBinaryOpStr(expr, op); idx >= 0 {
|
||||||
|
left := evalArithExpr(expr[:idx])
|
||||||
|
right := evalArithExpr(expr[idx+len(op):])
|
||||||
|
switch op {
|
||||||
|
case "<":
|
||||||
|
if left < right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case ">":
|
||||||
|
if left > right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "<=":
|
||||||
|
if left <= right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case ">=":
|
||||||
|
if left >= right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "==":
|
||||||
|
if left == right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "!=":
|
||||||
|
if left != right {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arithmetic operators in precedence order (lowest first so we split on last occurrence)
|
||||||
|
for _, op := range []string{"+", "-", "*", "/", "%"} {
|
||||||
|
if idx := findBinaryOp(expr, op); idx >= 0 {
|
||||||
|
left := evalArithExpr(expr[:idx])
|
||||||
|
right := evalArithExpr(expr[idx+1:])
|
||||||
|
switch op {
|
||||||
|
case "+":
|
||||||
|
return left + right
|
||||||
|
case "-":
|
||||||
|
return left - right
|
||||||
|
case "*":
|
||||||
|
return left * right
|
||||||
|
case "/":
|
||||||
|
if right == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return left / right
|
||||||
|
case "%":
|
||||||
|
if right == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return left % right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBinaryOpStr finds the rightmost occurrence of a multi-character binary operator.
|
||||||
|
func findBinaryOpStr(expr, op string) int {
|
||||||
|
depth := 0
|
||||||
|
// Search right-to-left
|
||||||
|
for i := len(expr) - len(op); i >= 0; i-- {
|
||||||
|
switch expr[i] {
|
||||||
|
case ')':
|
||||||
|
depth++
|
||||||
|
case '(':
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
if depth != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expr[i:i+len(op)] == op {
|
||||||
|
// Make sure it's not part of a longer operator
|
||||||
|
// e.g. don't match < in <=
|
||||||
|
if len(op) == 1 {
|
||||||
|
// For < and >, make sure next char is not =
|
||||||
|
if i+1 < len(expr) && (expr[i+1] == '=' || (op == "<" && expr[i+1] == '<') || (op == ">" && expr[i+1] == '>')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For single char ops, make sure previous char is not the same op (e.g. << or >>)
|
||||||
|
if len(op) == 1 && i > 0 && expr[i-1] == expr[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBinaryOp(expr, op string) int {
|
||||||
|
depth := 0
|
||||||
|
for i := len(expr) - 1; i >= 0; i-- {
|
||||||
|
switch expr[i] {
|
||||||
|
case ')':
|
||||||
|
depth++
|
||||||
|
case '(':
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
if depth != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expr[i:i+1] == op {
|
||||||
|
if (op == "-" || op == "+") && i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandGlob expands glob patterns; returns original if no match.
|
||||||
|
func (s *Shell) expandGlob(word string) []string {
|
||||||
|
if !strings.ContainsAny(word, "*?[") {
|
||||||
|
return []string{word}
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(word)
|
||||||
|
if err != nil || len(matches) == 0 {
|
||||||
|
return []string{word}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits input into tokens, expands variables, handles quotes and globs.
|
||||||
|
func (s *Shell) tokenize(input string) []string {
|
||||||
|
var rawTokens []string
|
||||||
|
current := strings.Builder{}
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
parenDepth := 0 // nesting depth inside $(...) or $((...))
|
||||||
|
pendingDollar := false // true after $ when next char is (
|
||||||
|
wasQuoted := false
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if current.Len() > 0 {
|
||||||
|
tok := current.String()
|
||||||
|
if wasQuoted {
|
||||||
|
tok = "\x00q" + tok
|
||||||
|
}
|
||||||
|
rawTokens = append(rawTokens, tok)
|
||||||
|
current.Reset()
|
||||||
|
wasQuoted = false
|
||||||
|
pendingDollar = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
c := input[i]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble && parenDepth == 0:
|
||||||
|
inSingle = !inSingle
|
||||||
|
wasQuoted = true
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '"' && !inSingle && parenDepth == 0:
|
||||||
|
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] == '(' {
|
||||||
|
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:
|
||||||
|
parenDepth++
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '}' && !inSingle && !inDouble && parenDepth > 0:
|
||||||
|
parenDepth--
|
||||||
|
current.WriteByte(c)
|
||||||
|
case (c == ' ' || c == '\t') && !inSingle && !inDouble && parenDepth == 0:
|
||||||
|
flush()
|
||||||
|
case c == '#' && !inSingle && !inDouble && parenDepth == 0 && current.Len() == 0:
|
||||||
|
// Inline comment: # at start of a new token — discard the rest of the input
|
||||||
|
goto doneTokenizing
|
||||||
|
default:
|
||||||
|
pendingDollar = false
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneTokenizing:
|
||||||
|
flush()
|
||||||
|
|
||||||
|
// Handle variable assignment on token[0]: FOO=bar
|
||||||
|
if len(rawTokens) > 0 {
|
||||||
|
tok := rawTokens[0]
|
||||||
|
clean := strings.TrimPrefix(tok, "\x00q")
|
||||||
|
if eqIdx := strings.Index(clean, "="); eqIdx > 0 {
|
||||||
|
name := clean[:eqIdx]
|
||||||
|
if isValidIdentifier(name) && !strings.Contains(clean[:eqIdx], "$") {
|
||||||
|
value := strings.ReplaceAll(s.expandWord(clean[eqIdx+1:]), "\x01", " ")
|
||||||
|
s.vars[name] = value
|
||||||
|
os.Setenv(name, value)
|
||||||
|
rawTokens = rawTokens[1:]
|
||||||
|
if len(rawTokens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, tok := range rawTokens {
|
||||||
|
quoted := strings.HasPrefix(tok, "\x00q")
|
||||||
|
if quoted {
|
||||||
|
tok = tok[2:]
|
||||||
|
}
|
||||||
|
expanded := s.expandWord(tok)
|
||||||
|
// 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 != "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !quoted && strings.ContainsAny(expanded, "*?[") {
|
||||||
|
result = append(result, s.expandGlob(expanded)...)
|
||||||
|
} else {
|
||||||
|
result = append(result, expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
787
internal/shell/shell.go
Normal file
787
internal/shell/shell.go
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
package shell
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for control flow
|
||||||
|
type breakErr struct{ n int }
|
||||||
|
type continueErr struct{ n int }
|
||||||
|
type returnErr struct{ code int }
|
||||||
|
|
||||||
|
func (e breakErr) Error() string { return fmt.Sprintf("break %d", e.n) }
|
||||||
|
func (e continueErr) Error() string { return fmt.Sprintf("continue %d", e.n) }
|
||||||
|
func (e returnErr) Error() string { return fmt.Sprintf("return %d", e.code) }
|
||||||
|
|
||||||
|
// exitCodeErr carries a non-zero exit code that sets $? without a message.
|
||||||
|
// Used by functions, test, false, etc.
|
||||||
|
type exitCodeErr struct{ code int }
|
||||||
|
|
||||||
|
func (e exitCodeErr) Error() string { return "" }
|
||||||
|
|
||||||
|
type Shell struct {
|
||||||
|
vars map[string]string
|
||||||
|
arrays map[string][]string
|
||||||
|
namerefs map[string]string
|
||||||
|
builtins map[string]func([]string) error
|
||||||
|
funcs map[string]string // function name → body
|
||||||
|
lastExit int
|
||||||
|
Stdin io.Reader
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
args []string
|
||||||
|
errexit bool
|
||||||
|
nounset bool
|
||||||
|
pipefail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Shell {
|
||||||
|
s := &Shell{
|
||||||
|
vars: map[string]string{},
|
||||||
|
arrays: map[string][]string{},
|
||||||
|
namerefs: map[string]string{},
|
||||||
|
funcs: map[string]string{},
|
||||||
|
Stdin: os.Stdin,
|
||||||
|
Stdout: os.Stdout,
|
||||||
|
Stderr: os.Stderr,
|
||||||
|
}
|
||||||
|
s.initBuiltins()
|
||||||
|
s.vars["SHELL"] = "bash-for-windows"
|
||||||
|
s.vars["BASH_VERSION"] = "5.2.15(1)-release"
|
||||||
|
s.vars["?"] = "0"
|
||||||
|
s.vars["#"] = "0"
|
||||||
|
s.vars["@"] = ""
|
||||||
|
s.vars["*"] = ""
|
||||||
|
s.vars["!"] = ""
|
||||||
|
if pwd, err := os.Getwd(); err == nil {
|
||||||
|
s.vars["PWD"] = pwd
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
s.vars["HOME"] = home
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNR resolves a variable name through the nameref chain (circular-ref safe).
|
||||||
|
func (s *Shell) resolveNR(name string) string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for {
|
||||||
|
if seen[name] {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
target, ok := s.namerefs[name]
|
||||||
|
if !ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
name = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getArray returns array (resolving namerefs).
|
||||||
|
func (s *Shell) getArray(name string) []string {
|
||||||
|
return s.arrays[s.resolveNR(name)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setArray sets array (resolving namerefs).
|
||||||
|
func (s *Shell) setArray(name string, vals []string) {
|
||||||
|
s.arrays[s.resolveNR(name)] = vals
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendArray appends to array (resolving namerefs).
|
||||||
|
func (s *Shell) appendArray(name string, vals []string) {
|
||||||
|
n := s.resolveNR(name)
|
||||||
|
s.arrays[n] = append(s.arrays[n], vals...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) SetArgs(args []string) {
|
||||||
|
s.args = args
|
||||||
|
s.vars["#"] = fmt.Sprintf("%d", len(args))
|
||||||
|
s.vars["@"] = strings.Join(args, " ")
|
||||||
|
s.vars["*"] = strings.Join(args, " ")
|
||||||
|
for i, a := range args {
|
||||||
|
s.vars[fmt.Sprintf("%d", i+1)] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) GetVar(name string) string {
|
||||||
|
if v, ok := s.vars[name]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return os.Getenv(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) SetVar(name, value string) {
|
||||||
|
s.vars[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
input = strings.ReplaceAll(input, "\r", "\n")
|
||||||
|
input = strings.ReplaceAll(input, "\\\n", " ")
|
||||||
|
// Pre-process heredocs
|
||||||
|
input = preprocessHeredocs(input)
|
||||||
|
blocks := parseBlocks(input)
|
||||||
|
for _, block := range blocks {
|
||||||
|
if err := s.executeBlock(block); err != nil {
|
||||||
|
switch err.(type) {
|
||||||
|
case breakErr, continueErr, returnErr:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.setExitCode(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIncomplete returns true if the input is an incomplete multi-line construct.
|
||||||
|
func IsIncomplete(input string) bool {
|
||||||
|
stmts := splitStatements(input)
|
||||||
|
depth := 0
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
for _, ch := range input {
|
||||||
|
switch ch {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inSingle || inDouble {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
w := firstWord(stmt)
|
||||||
|
switch w {
|
||||||
|
case "if", "for", "while", "until", "case":
|
||||||
|
depth++
|
||||||
|
case "fi", "done", "esac":
|
||||||
|
depth--
|
||||||
|
case "{":
|
||||||
|
depth++
|
||||||
|
case "}":
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isFuncDefStart(stmt) && strings.HasSuffix(strings.TrimSpace(stmt), "{") {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBlocks groups statements into logical execution units.
|
||||||
|
// Multi-line if/for/while/function blocks are gathered into single entries.
|
||||||
|
func parseBlocks(input string) []string {
|
||||||
|
stmts := splitStatements(input)
|
||||||
|
var blocks []string
|
||||||
|
var current []string
|
||||||
|
kwDepth := 0 // if/for/while/until → fi/done nesting
|
||||||
|
inFunc := false
|
||||||
|
funcKwDepth := 0 // keyword nesting inside a function body
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" || strings.HasPrefix(stmt, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := firstWord(stmt)
|
||||||
|
|
||||||
|
if !inFunc {
|
||||||
|
// Detect function definition opening with `{`
|
||||||
|
if isFuncDefStart(stmt) && strings.Contains(stmt, "{") {
|
||||||
|
braceIdx := strings.Index(stmt, "{")
|
||||||
|
// Count keywords after the { on this same line
|
||||||
|
funcKwDepth = 0
|
||||||
|
for _, p := range splitStatements(stmt[braceIdx+1:]) {
|
||||||
|
switch firstWord(p) {
|
||||||
|
case "if", "for", "while", "until", "case", "{":
|
||||||
|
funcKwDepth++
|
||||||
|
case "fi", "done", "esac", "}":
|
||||||
|
funcKwDepth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the line also ends with } it's a self-contained function
|
||||||
|
if strings.HasSuffix(stmt, "}") {
|
||||||
|
current = append(current, stmt)
|
||||||
|
blocks = append(blocks, strings.Join(current, "\n"))
|
||||||
|
current = nil
|
||||||
|
funcKwDepth = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inFunc = true
|
||||||
|
current = append(current, stmt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch w {
|
||||||
|
case "if", "for", "while", "until", "case", "{":
|
||||||
|
kwDepth++
|
||||||
|
}
|
||||||
|
kwDepth += embeddedKwDepth(stmt)
|
||||||
|
current = append(current, stmt)
|
||||||
|
switch w {
|
||||||
|
case "fi", "done", "esac":
|
||||||
|
kwDepth--
|
||||||
|
case "}":
|
||||||
|
if kwDepth > 0 {
|
||||||
|
kwDepth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kwDepth <= 0 && len(current) > 0 {
|
||||||
|
kwDepth = 0
|
||||||
|
blocks = append(blocks, strings.Join(current, "\n"))
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Inside function body — watch for } at funcKwDepth==0
|
||||||
|
if w == "}" && funcKwDepth <= 0 {
|
||||||
|
current = append(current, stmt)
|
||||||
|
blocks = append(blocks, strings.Join(current, "\n"))
|
||||||
|
current = nil
|
||||||
|
inFunc = false
|
||||||
|
funcKwDepth = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch w {
|
||||||
|
case "if", "for", "while", "until", "case", "{":
|
||||||
|
funcKwDepth++
|
||||||
|
case "fi", "done", "esac", "}":
|
||||||
|
funcKwDepth--
|
||||||
|
}
|
||||||
|
funcKwDepth += embeddedKwDepth(stmt)
|
||||||
|
current = append(current, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(current) > 0 {
|
||||||
|
blocks = append(blocks, strings.Join(current, "\n"))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
for j := 1; j < len(words); j++ {
|
||||||
|
switch words[j-1] {
|
||||||
|
case "do", "then", "else", "elif":
|
||||||
|
switch words[j] {
|
||||||
|
case "if", "for", "while", "until":
|
||||||
|
delta++
|
||||||
|
case "fi", "done", "esac":
|
||||||
|
delta--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// preprocessHeredocs converts heredoc syntax (<<MARKER) to temp-file redirects.
|
||||||
|
// It processes the input line by line, detecting <<MARKER, collecting the body,
|
||||||
|
// writing it to a temp file, and replacing <<MARKER with < /tmp/file.
|
||||||
|
func preprocessHeredocs(input string) string {
|
||||||
|
lines := strings.Split(input, "\n")
|
||||||
|
var result []string
|
||||||
|
i := 0
|
||||||
|
for i < len(lines) {
|
||||||
|
line := lines[i]
|
||||||
|
// Check for heredoc marker in this line: <<MARKER or <<-MARKER or <<"MARKER" or <<'MARKER'
|
||||||
|
// Find all heredoc markers on this line (there could be multiple)
|
||||||
|
processed, markers := parseHeredocMarkers(line)
|
||||||
|
if len(markers) == 0 {
|
||||||
|
result = append(result, line)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Collect heredoc bodies for each marker
|
||||||
|
i++
|
||||||
|
newLine := processed
|
||||||
|
for _, marker := range markers {
|
||||||
|
stripTabs := strings.HasPrefix(marker, "-")
|
||||||
|
if stripTabs {
|
||||||
|
marker = marker[1:]
|
||||||
|
}
|
||||||
|
// Strip quotes from marker
|
||||||
|
quotedMarker := marker
|
||||||
|
if len(marker) >= 2 && ((marker[0] == '\'' && marker[len(marker)-1] == '\'') ||
|
||||||
|
(marker[0] == '"' && marker[len(marker)-1] == '"')) {
|
||||||
|
marker = marker[1 : len(marker)-1]
|
||||||
|
}
|
||||||
|
_ = quotedMarker
|
||||||
|
// Collect body until marker
|
||||||
|
var bodyLines []string
|
||||||
|
for i < len(lines) {
|
||||||
|
bodyLine := lines[i]
|
||||||
|
check := bodyLine
|
||||||
|
if stripTabs {
|
||||||
|
check = strings.TrimLeft(check, "\t")
|
||||||
|
}
|
||||||
|
if strings.TrimRight(check, "\r") == marker {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bodyLines = append(bodyLines, bodyLine)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Write to temp file
|
||||||
|
content := strings.Join(bodyLines, "\n")
|
||||||
|
if len(bodyLines) > 0 {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp("", "heredoc*")
|
||||||
|
if err == nil {
|
||||||
|
f.WriteString(content)
|
||||||
|
f.Close()
|
||||||
|
newLine += " < " + f.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, newLine)
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeredocMarkers finds <<MARKER patterns in a line and returns the line with
|
||||||
|
// the heredoc redirects removed (replaced), plus the list of markers found.
|
||||||
|
func parseHeredocMarkers(line string) (string, []string) {
|
||||||
|
var markers []string
|
||||||
|
var out strings.Builder
|
||||||
|
i := 0
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
|
||||||
|
for i < len(line) {
|
||||||
|
c := line[i]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble:
|
||||||
|
inSingle = !inSingle
|
||||||
|
out.WriteByte(c)
|
||||||
|
i++
|
||||||
|
case c == '"' && !inSingle:
|
||||||
|
inDouble = !inDouble
|
||||||
|
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
|
||||||
|
if i < len(line) && line[i] == '-' {
|
||||||
|
stripTabs = true
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Skip spaces after <<
|
||||||
|
for i < len(line) && line[i] == ' ' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Read the marker (may be quoted)
|
||||||
|
marker := ""
|
||||||
|
if i < len(line) && (line[i] == '\'' || line[i] == '"') {
|
||||||
|
quote := line[i]
|
||||||
|
i++
|
||||||
|
for i < len(line) && line[i] != quote {
|
||||||
|
marker += string(line[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < len(line) {
|
||||||
|
i++ // skip closing quote
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unquoted marker
|
||||||
|
for i < len(line) && line[i] != ' ' && line[i] != '\t' && line[i] != ';' && line[i] != '|' && line[i] != '&' && line[i] != '>' && line[i] != '<' {
|
||||||
|
marker += string(line[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if marker != "" {
|
||||||
|
if stripTabs {
|
||||||
|
markers = append(markers, "-"+marker)
|
||||||
|
} else {
|
||||||
|
markers = append(markers, marker)
|
||||||
|
}
|
||||||
|
// Don't write the <<MARKER to output; it will be replaced by < file
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out.WriteByte(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.String(), markers
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStatements splits input on semicolons and newlines, respecting quotes and ((...)).
|
||||||
|
// Double semicolons ;; are preserved as a single token (for case statements).
|
||||||
|
func splitStatements(input string) []string {
|
||||||
|
var result []string
|
||||||
|
current := strings.Builder{}
|
||||||
|
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]
|
||||||
|
switch {
|
||||||
|
case c == '\'' && !inDouble && parenDepth == 0:
|
||||||
|
inSingle = !inSingle
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '"' && !inSingle && parenDepth == 0:
|
||||||
|
inDouble = !inDouble
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '(' && !inSingle && !inDouble:
|
||||||
|
parenDepth++
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
|
||||||
|
parenDepth--
|
||||||
|
current.WriteByte(c)
|
||||||
|
// 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 != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
result = append(result, ";;")
|
||||||
|
i++ // skip second ;
|
||||||
|
} else {
|
||||||
|
// Single semicolon — just a statement separator
|
||||||
|
if s := strings.TrimSpace(current.String()); s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
case c == '\n' && !inSingle && !inDouble && parenDepth == 0 && braceDepth == 0:
|
||||||
|
if s := strings.TrimSpace(current.String()); s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
default:
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(current.String()); s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstWord(s string) string {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fields[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func afterWord(s string) string {
|
||||||
|
for i, ch := range s {
|
||||||
|
if ch == ' ' || ch == '\t' {
|
||||||
|
return strings.TrimSpace(s[i:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFuncDefStart(stmt string) bool {
|
||||||
|
if strings.HasPrefix(stmt, "function ") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i, ch := range stmt {
|
||||||
|
if ch == ' ' || ch == '\t' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ch == '(' {
|
||||||
|
name := strings.TrimSpace(stmt[:i])
|
||||||
|
return isValidIdentifier(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidIdentifier(s string) bool {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, c := range s {
|
||||||
|
if i == 0 {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) executeBlock(block string) error {
|
||||||
|
block = strings.TrimSpace(block)
|
||||||
|
if block == "" || strings.HasPrefix(block, "#") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := firstWord(block)
|
||||||
|
switch w {
|
||||||
|
case "if":
|
||||||
|
return s.executeIf(block)
|
||||||
|
case "for":
|
||||||
|
// Check for C-style for (( ... ))
|
||||||
|
trimmed := strings.TrimSpace(block)
|
||||||
|
if strings.HasPrefix(trimmed, "for ((") || strings.HasPrefix(trimmed, "for((") {
|
||||||
|
return s.executeForC(block)
|
||||||
|
}
|
||||||
|
return s.executeFor(block)
|
||||||
|
case "while":
|
||||||
|
return s.executeWhileUntil(block, false)
|
||||||
|
case "until":
|
||||||
|
return s.executeWhileUntil(block, true)
|
||||||
|
case "case":
|
||||||
|
return s.executeCase(block)
|
||||||
|
}
|
||||||
|
if isFuncDefStart(block) {
|
||||||
|
return s.defineFunction(block)
|
||||||
|
}
|
||||||
|
// 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(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) executeLine(line string) error {
|
||||||
|
return s.executeChain(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) executeChain(line string) error {
|
||||||
|
for _, seg := range splitBySemicolon(line) {
|
||||||
|
seg = strings.TrimSpace(seg)
|
||||||
|
if seg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.executeAndOrList(seg); err != nil {
|
||||||
|
switch err.(type) {
|
||||||
|
case breakErr, continueErr, returnErr:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.setExitCode(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitBySemicolon(line string) []string {
|
||||||
|
var parts []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 && parenDepth == 0:
|
||||||
|
inSingle = !inSingle
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '"' && !inSingle && parenDepth == 0:
|
||||||
|
inDouble = !inDouble
|
||||||
|
current.WriteByte(c)
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
parts = append(parts, current.String())
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) executeAndOrList(line string) error {
|
||||||
|
type tok struct {
|
||||||
|
text string
|
||||||
|
op string
|
||||||
|
}
|
||||||
|
var tokens []tok
|
||||||
|
current := strings.Builder{}
|
||||||
|
op := ""
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
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 && parenDepth == 0:
|
||||||
|
inSingle = !inSingle
|
||||||
|
current.WriteByte(c)
|
||||||
|
case c == '"' && !inSingle && parenDepth == 0:
|
||||||
|
inDouble = !inDouble
|
||||||
|
current.WriteByte(c)
|
||||||
|
// 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 && 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 && 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 && 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
tokens = append(tokens, tok{current.String(), op})
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for i, t := range tokens {
|
||||||
|
cmd := strings.TrimSpace(t.text)
|
||||||
|
if cmd == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run := i == 0
|
||||||
|
if i > 0 {
|
||||||
|
run = (t.op == "&&" && lastErr == nil) || (t.op == "||" && lastErr != nil)
|
||||||
|
}
|
||||||
|
if run {
|
||||||
|
err := s.executePipeline(cmd)
|
||||||
|
lastErr = err
|
||||||
|
s.setExitCode(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) setExitCode(err error) {
|
||||||
|
if err == nil {
|
||||||
|
s.vars["?"] = "0"
|
||||||
|
s.lastExit = 0
|
||||||
|
} else if ec, ok := err.(exitCodeErr); ok {
|
||||||
|
s.vars["?"] = fmt.Sprintf("%d", ec.code)
|
||||||
|
s.lastExit = ec.code
|
||||||
|
} else {
|
||||||
|
s.vars["?"] = "1"
|
||||||
|
s.lastExit = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuiltinNames returns a sorted list of all registered builtin names (for tab completion).
|
||||||
|
func (s *Shell) BuiltinNames() []string {
|
||||||
|
names := make([]string, 0, len(s.builtins)+len(s.funcs))
|
||||||
|
for k := range s.builtins {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
for k := range s.funcs {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// withIO temporarily swaps stdin/stdout/stderr, runs fn, then restores.
|
||||||
|
// Pass nil to leave the corresponding stream unchanged.
|
||||||
|
func (s *Shell) withIO(stdin io.Reader, stdout io.Writer, stderr io.Writer, fn func() error) error {
|
||||||
|
oldIn, oldOut, oldErr := s.Stdin, s.Stdout, s.Stderr
|
||||||
|
if stdin != nil {
|
||||||
|
s.Stdin = stdin
|
||||||
|
}
|
||||||
|
if stdout != nil {
|
||||||
|
s.Stdout = stdout
|
||||||
|
}
|
||||||
|
if stderr != nil {
|
||||||
|
s.Stderr = stderr
|
||||||
|
}
|
||||||
|
err := fn()
|
||||||
|
s.Stdin, s.Stdout, s.Stderr = oldIn, oldOut, oldErr
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user