Compare commits

...

6 Commits

Author SHA1 Message Date
Cametendo
9037606447 fixed bash-for-windows 2026-05-31 21:49:13 +02:00
Cametendo
7b3a101946 WIP: Waifufetch by JGH0 working in windows-bash 2026-05-28 21:00:34 +02:00
114cbf43bd Update README.md 2026-05-28 17:10:11 +02:00
Cametendo
66cf9f15d2 Fix install instructions: build from source first, no release yet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:10:50 +02:00
Cametendo
3b502f5516 Update README to reflect current feature set and installer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:06:02 +02:00
Cametendo
dd388a7469 Add Windows Terminal installer (install.ps1 / install.bat)
install.ps1 copies bash.exe to %LOCALAPPDATA%\Programs\BashForWindows,
adds it to the user PATH, and injects a "Bash for Windows" profile into
Windows Terminal's settings.json so the shell appears in the + dropdown.

install.bat is a double-click wrapper that bypasses the PS execution policy.

build.sh --release produces a release/ folder ready to zip and distribute.
Supports -Uninstall flag to cleanly remove everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:01:20 +02:00
11 changed files with 2113 additions and 227 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
build/ build/
release/
*.exe *.exe
*.o *.o
tmp/ tmp/
waifufetch/

170
README.md
View File

@@ -1,62 +1,164 @@
# bash-for-windows # bash-for-windows
A fully functional bash shell for Windows, written in Go. Run bash commands and scripts natively on Windows without WSL, Cygwin, or MSYS2. 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 ## Features
- **Interactive shell** with prompt (`bash$`) ### Shell language
- **Built-in commands**: cd, pwd, echo, exit, export, source, alias, type - **Control flow** — `if/elif/else/fi`, `for/do/done`, `while/until`
- **Built-in coreutils**: ls, cat, grep, sort, wc, head, find, cp, mv, rm, mkdir, touch, clear - **Functions** — `name() { ... }` and `function name { ... }`, with `local`/`return`
- **Command chaining**: `&&`, `||`, `;` - **Arithmetic** — `$(( expr ))`, `$((i + 1))`, `$((n % 2))`
- **Pipes**: `|` between commands - **Command substitution** — `$(cmd)`, including pipelines: `x=$(echo foo | tr a-z A-Z)`
- **Variable expansion**: `$NAME`, `${NAME}` - **Variable expansion**`$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR:=val}`, `${#VAR}`, `${VAR%suffix}`, `${VAR#prefix}`
- **Variable assignment**: `NAME=VALUE command` - **Glob expansion** — `*.txt`, `src/**`
- **Single & double quotes**: `'literal'`, `"$variable"` - **Tilde expansion** — `~/Documents`
- **Script execution**: `bash-windows script.sh` or `-c 'commands'` - **Quotes** — single `'literal'`, double `"with $vars"`, backslash escapes
- **No dependencies** — single `.exe` file, runs on any Windows x86-64 - **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 ## Usage
``` ```
bash-windows # Interactive mode bash # interactive shell
bash-windows -c 'echo hello' bash -c 'echo hello' # run a command string
bash-windows script.sh 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 ### Examples
``` ```bash
bash$ echo "Hello from bash-for-windows!" # Variables and arithmetic
bash$ ls -la name="World"
bash$ cd /tmp && pwd echo "Hello $name"
bash$ cat file.txt | grep pattern | wc -l echo $((40 + 2))
bash$ name="Luffy" && echo $name
bash$ mkdir -p project/src && touch project/src/main.go # 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
``` ```
## Building ## Build from source
Requires Go 1.21+. Requires Go 1.21+.
```bash ```bash
# Linux ./build.sh # builds Linux debug + Windows .exe into build/
./build.sh ./build.sh --release # also creates release/ folder ready to distribute
# Manual
go build -o build/bash-windows .
# Windows cross-compile
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o build/bash-windows.exe .
``` ```
## Project Structure ```bash
# Manual cross-compile
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bash.exe .
```
## Project layout
``` ```
cmd/bash/ - Shell entry point cmd/bash/ entry point, readline REPL
internal/shell/ - Shell engine (parser, executor, builtins, coreutils) internal/shell/
build/ - Compiled binaries 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 ## License
MIT 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.

View File

@@ -2,18 +2,39 @@
set -e set -e
BUILD_DIR="build" BUILD_DIR="build"
RELEASE_DIR="release"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
echo "=== Building bash-for-windows ===" echo "=== Building bash-for-windows ==="
# Linux build # Linux debug build
echo " -> Linux..." echo " -> Linux (debug)..."
go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows" . go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows" .
# Windows cross-compile # Windows release build
echo " -> Windows (x86_64)..." echo " -> Windows (x86_64)..."
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows.exe" . GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags="-s -w" -o "$BUILD_DIR/bash-windows.exe" .
echo "" echo ""
echo "=== Build complete ===" echo "=== Build complete ==="
ls -lh "$BUILD_DIR/" 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

View File

@@ -43,7 +43,7 @@ func runFile(path string, args []string) error {
return fmt.Errorf("%s: %v", path, err) return fmt.Errorf("%s: %v", path, err)
} }
sh := shell.New() sh := shell.New()
sh.SetArgs(append([]string{path}, args...)) sh.SetArgs(args)
sh.SetVar("0", path) sh.SetVar("0", path)
return sh.Execute(string(data)) return sh.Execute(string(data))
} }
@@ -60,12 +60,7 @@ func historyFile() string {
func interactive() error { func interactive() error {
sh := shell.New() sh := shell.New()
// Build completer completer := &shellCompleter{sh: sh}
completer := readline.NewPrefixCompleter(
readline.PcItemDynamic(func(line string) []string {
return dynamicComplete(sh, line)
}),
)
rl, err := readline.NewEx(&readline.Config{ rl, err := readline.NewEx(&readline.Config{
HistoryFile: historyFile(), HistoryFile: historyFile(),
@@ -183,6 +178,56 @@ func buildPrompt(sh *shell.Shell) string {
return pwd + suffix 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. // dynamicComplete provides tab completion for commands and paths.
func dynamicComplete(sh *shell.Shell, line string) []string { func dynamicComplete(sh *shell.Shell, line string) []string {
line = strings.TrimLeft(line, " \t") line = strings.TrimLeft(line, " \t")
@@ -241,7 +286,7 @@ func dynamicComplete(sh *shell.Shell, line string) []string {
if strings.HasPrefix(name, base) { if strings.HasPrefix(name, base) {
p := filepath.Join(dir, name) p := filepath.Join(dir, name)
if e.IsDir() { if e.IsDir() {
p += "/" p += string(filepath.Separator)
} }
completions = append(completions, p) completions = append(completions, p)
} }

19
install.bat Normal file
View 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
View 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 ""

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
@@ -17,60 +18,70 @@ var aliases = make(map[string]string)
func (s *Shell) initBuiltins() { func (s *Shell) initBuiltins() {
s.builtins = map[string]func([]string) error{ s.builtins = map[string]func([]string) error{
"{": s.builtinTrue,
"}": s.builtinTrue,
// Shell builtins // Shell builtins
"cd": s.builtinCd, "cd": s.builtinCd,
"pwd": s.builtinPwd, "pwd": s.builtinPwd,
"echo": s.builtinEcho, "echo": s.builtinEcho,
"exit": s.builtinExit, "exit": s.builtinExit,
"export": s.builtinExport, "export": s.builtinExport,
"source": s.builtinSource, "source": s.builtinSource,
".": s.builtinSource, ".": s.builtinSource,
"alias": s.builtinAlias, "alias": s.builtinAlias,
"unalias": s.builtinUnalias, "unalias": s.builtinUnalias,
"type": s.builtinType, "type": s.builtinType,
"test": s.builtinTest, "test": s.builtinTest,
"[": s.builtinTest, "[": s.builtinTest,
"read": s.builtinRead, "[[": s.builtinDoubleBracket,
"printf": s.builtinPrintf, "read": s.builtinRead,
"true": s.builtinTrue, "printf": s.builtinPrintf,
"false": s.builtinFalse, "true": s.builtinTrue,
"set": s.builtinSet, "false": s.builtinFalse,
"unset": s.builtinUnset, "set": s.builtinSet,
"env": s.builtinEnv, "unset": s.builtinUnset,
"which": s.builtinWhich, "env": s.builtinEnv,
"return": s.builtinReturn, "which": s.builtinWhich,
"break": s.builtinBreak, "return": s.builtinReturn,
"continue": s.builtinContinue, "break": s.builtinBreak,
"shift": s.builtinShift, "continue": s.builtinContinue,
"declare": s.builtinDeclare, "shift": s.builtinShift,
"local": s.builtinDeclare, "declare": s.builtinDeclare,
"command": s.builtinCommand, "local": s.builtinDeclare,
"jobs": s.builtinJobs, "command": s.builtinCommand,
"jobs": s.builtinJobs,
"disown": s.builtinDisown,
"mktemp": s.builtinMktemp,
"uname": s.builtinUname,
"whoami": s.builtinWhoami,
"hostname": s.builtinHostname,
"mapfile": s.builtinMapfile,
"readarray": s.builtinMapfile,
// Coreutils // Coreutils
"ls": s.cmdLs, "ls": s.cmdLs,
"cat": s.cmdCat, "cat": s.cmdCat,
"grep": s.cmdGrep, "grep": s.cmdGrep,
"head": s.cmdHead, "head": s.cmdHead,
"tail": s.cmdTail, "tail": s.cmdTail,
"sort": s.cmdSort, "sort": s.cmdSort,
"wc": s.cmdWc, "wc": s.cmdWc,
"find": s.cmdFind, "find": s.cmdFind,
"cp": s.cmdCp, "cp": s.cmdCp,
"mv": s.cmdMv, "mv": s.cmdMv,
"rm": s.cmdRm, "rm": s.cmdRm,
"mkdir": s.cmdMkdir, "mkdir": s.cmdMkdir,
"touch": s.cmdTouch, "touch": s.cmdTouch,
"clear": s.cmdClear, "clear": s.cmdClear,
"cut": s.cmdCut, "cut": s.cmdCut,
"tr": s.cmdTr, "tr": s.cmdTr,
"uniq": s.cmdUniq, "uniq": s.cmdUniq,
"tee": s.cmdTee, "tee": s.cmdTee,
"date": s.cmdDate, "date": s.cmdDate,
"sleep": s.cmdSleep, "sleep": s.cmdSleep,
"basename": s.cmdBasename, "basename": s.cmdBasename,
"dirname": s.cmdDirname, "dirname": s.cmdDirname,
"sed": s.cmdSed, "sed": s.cmdSed,
"xargs": s.cmdXargs, "xargs": s.cmdXargs,
} }
} }
@@ -433,84 +444,111 @@ func (s *Shell) builtinPrintf(args []string) error {
fmtArgs := args[1:] fmtArgs := args[1:]
var result strings.Builder var result strings.Builder
// POSIX: if more arguments than format specifiers, re-apply the format
// until all args are consumed. Track consumed count per pass to detect
// a format with no specifiers (avoid infinite loop).
argIdx := 0 argIdx := 0
i := 0 for {
for i < len(format) { consumed := 0
if format[i] == '%' && i+1 < len(format) { i := 0
i++ for i < len(format) {
// Optional width/precision if format[i] == '%' && i+1 < len(format) {
specStart := i i++
for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') { // Optional width/precision flags
specStart := i
for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') {
i++
}
spec := format[specStart:i]
if i >= len(format) {
break
}
arg := ""
if argIdx < len(fmtArgs) {
arg = fmtArgs[argIdx]
argIdx++
consumed++
}
switch format[i] {
case 's':
if spec != "" {
result.WriteString(fmt.Sprintf("%-"+spec+"s", arg)) //nolint
} else {
result.WriteString(arg)
}
case 'd':
n := toInt(arg)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"d", n))
} else {
result.WriteString(strconv.Itoa(n))
}
case 'f':
f, _ := strconv.ParseFloat(arg, 64)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"f", f))
} else {
result.WriteString(fmt.Sprintf("%f", f))
}
case 'x':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%x", n))
case 'o':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%o", n))
case '%':
result.WriteByte('%')
if consumed > 0 {
consumed-- // %% consumes no arg
}
default:
result.WriteByte('%')
result.WriteByte(format[i])
if consumed > 0 {
consumed--
}
}
i++
} else if format[i] == '\\' && i+1 < len(format) {
i++
switch format[i] {
case 'n':
result.WriteByte('\n')
case 't':
result.WriteByte('\t')
case 'r':
result.WriteByte('\r')
case '\\':
result.WriteByte('\\')
case 'a':
result.WriteByte('\a')
case 'b':
result.WriteByte('\b')
case 'e', 'E':
result.WriteByte(0x1b) // ESC
case '0', '1', '2', '3', '4', '5', '6', '7':
// Octal escape \NNN (up to 3 octal digits)
oct := int(format[i] - '0')
for k := 0; k < 2 && i+1 < len(format) && format[i+1] >= '0' && format[i+1] <= '7'; k++ {
i++
oct = oct*8 + int(format[i]-'0')
}
result.WriteByte(byte(oct))
default:
result.WriteByte('\\')
result.WriteByte(format[i])
}
i++
} else {
result.WriteByte(format[i])
i++ i++
} }
spec := format[specStart:i] }
if i >= len(format) { // Stop looping if all args consumed, no args given, or format has
break // no specifiers (consumed==0 means infinite loop risk).
} if argIdx >= len(fmtArgs) || consumed == 0 {
arg := "" break
if argIdx < len(fmtArgs) {
arg = fmtArgs[argIdx]
argIdx++
}
switch format[i] {
case 's':
if spec != "" {
result.WriteString(fmt.Sprintf("%-"+spec+"s", arg)) //nolint
} else {
result.WriteString(arg)
}
case 'd':
n := toInt(arg)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"d", n))
} else {
result.WriteString(strconv.Itoa(n))
}
case 'f':
f, _ := strconv.ParseFloat(arg, 64)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"f", f))
} else {
result.WriteString(fmt.Sprintf("%f", f))
}
case 'x':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%x", n))
case 'o':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%o", n))
case '%':
result.WriteByte('%')
argIdx-- // no arg consumed
default:
result.WriteByte('%')
result.WriteByte(format[i])
argIdx--
}
i++
} else if format[i] == '\\' && i+1 < len(format) {
i++
switch format[i] {
case 'n':
result.WriteByte('\n')
case 't':
result.WriteByte('\t')
case 'r':
result.WriteByte('\r')
case '\\':
result.WriteByte('\\')
case 'a':
result.WriteByte('\a')
case 'b':
result.WriteByte('\b')
default:
result.WriteByte('\\')
result.WriteByte(format[i])
}
i++
} else {
result.WriteByte(format[i])
i++
} }
} }
fmt.Fprint(s.Stdout, result.String()) fmt.Fprint(s.Stdout, result.String())
@@ -532,9 +570,58 @@ func (s *Shell) builtinSet(args []string) error {
} }
return nil return nil
} }
// Handle positional params: set -- a b c i := 0
if args[0] == "--" { for i < len(args) {
s.SetArgs(args[1:]) switch args[i] {
case "--":
s.SetArgs(args[i+1:])
return nil
case "-e":
s.errexit = true
case "+e":
s.errexit = false
case "-u":
s.nounset = true
case "+u":
s.nounset = false
case "-x":
// xtrace — ignore
case "+x":
// xtrace off — ignore
case "-o":
if i+1 < len(args) {
switch args[i+1] {
case "pipefail":
s.pipefail = true
i++
case "errexit":
s.errexit = true
i++
case "nounset":
s.nounset = true
i++
}
}
case "+o":
if i+1 < len(args) {
i++ // ignore value
}
default:
// Combined flags like -euo
if strings.HasPrefix(args[i], "-") {
for _, c := range args[i][1:] {
switch c {
case 'e':
s.errexit = true
case 'u':
s.nounset = true
case 'x':
// xtrace — ignore
}
}
}
}
i++
} }
return nil return nil
} }
@@ -619,14 +706,71 @@ func (s *Shell) builtinShift(args []string) error {
} }
func (s *Shell) builtinDeclare(args []string) error { func (s *Shell) builtinDeclare(args []string) error {
for _, arg := range args { // Parse flags
nameref := false
isArray := false
isExport := false
nonFlagStart := 0
for i, arg := range args {
if !strings.HasPrefix(arg, "-") {
nonFlagStart = i
break
}
nonFlagStart = i + 1
for _, ch := range arg[1:] {
switch ch {
case 'n':
nameref = true
case 'a':
isArray = true
case 'i':
// integer — treat as scalar
case 'r':
// readonly — ignore
case 'x':
isExport = true
case 'g':
// global — ignore
}
}
}
for _, arg := range args[nonFlagStart:] {
if strings.HasPrefix(arg, "-") { if strings.HasPrefix(arg, "-") {
continue continue
} }
if idx := strings.Index(arg, "="); idx > 0 { if idx := strings.Index(arg, "="); idx > 0 {
name := arg[:idx] name := arg[:idx]
if isValidIdentifier(name) { if !isValidIdentifier(name) {
s.vars[name] = arg[idx+1:] continue
}
value := s.expandWord(arg[idx+1:])
if nameref {
s.namerefs[name] = value
} else if isArray {
if strings.HasPrefix(value, "(") && strings.HasSuffix(value, ")") {
inner := value[1 : len(value)-1]
elems := s.tokenize(inner)
s.setArray(name, elems)
} else {
s.setArray(name, []string{value})
}
} else {
s.vars[name] = value
if isExport {
os.Setenv(name, value)
}
}
} else {
// No = — just declare
if !isValidIdentifier(arg) {
continue
}
if isArray {
if s.arrays[arg] == nil {
s.arrays[arg] = []string{}
}
} }
} }
} }
@@ -634,6 +778,23 @@ func (s *Shell) builtinDeclare(args []string) error {
} }
func (s *Shell) builtinCommand(args []string) error { func (s *Shell) builtinCommand(args []string) error {
if len(args) > 0 && args[0] == "-v" {
found := true
for _, name := range args[1:] {
if _, ok := s.builtins[name]; ok {
fmt.Fprintln(s.Stdout, name)
} else if p := findExecutable(name); p != "" {
fmt.Fprintln(s.Stdout, p)
} else {
found = false
fmt.Fprintf(s.Stderr, "bash: command not found: %s\n", name)
}
}
if !found {
return exitCodeErr{1}
}
return nil
}
if len(args) == 0 { if len(args) == 0 {
return nil return nil
} }
@@ -646,6 +807,291 @@ func (s *Shell) builtinJobs(_ []string) error {
return nil return nil
} }
func (s *Shell) builtinDisown(_ []string) error { return nil }
func (s *Shell) builtinMktemp(args []string) error {
template := ""
for _, a := range args {
if !strings.HasPrefix(a, "-") {
template = a
break
}
}
// Use Windows temp dir regardless of the /tmp/ prefix in template
tmpDir := os.Getenv("TEMP")
if tmpDir == "" {
tmpDir = os.Getenv("TMP")
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
// Extract the base pattern (strip directory prefix)
base := filepath.Base(template)
if base == "" || base == "." {
base = "tmp.XXXXXX"
}
// Replace trailing X's with * for os.CreateTemp
xCount := 0
for i := len(base) - 1; i >= 0 && base[i] == 'X'; i-- {
xCount++
}
goPattern := base
if xCount > 0 {
goPattern = base[:len(base)-xCount] + "*"
}
f, err := os.CreateTemp(tmpDir, goPattern)
if err != nil {
return fmt.Errorf("mktemp: %v", err)
}
f.Close()
fmt.Fprintln(s.Stdout, f.Name())
return nil
}
func (s *Shell) builtinUname(args []string) error {
showSys := len(args) == 0
showRelease := false
for _, a := range args {
switch a {
case "-s":
showSys = true
case "-r":
showRelease = true
case "-a":
showSys = true
showRelease = true
}
}
if showSys {
fmt.Fprintln(s.Stdout, "Windows_NT")
}
if showRelease {
kernelRel := os.Getenv("OS_VERSION")
if kernelRel == "" {
kernelRel = "10.0"
}
fmt.Fprintln(s.Stdout, kernelRel)
}
return nil
}
func (s *Shell) builtinWhoami(_ []string) error {
user := os.Getenv("USERNAME")
if user == "" {
user = os.Getenv("USER")
}
if user == "" {
user = "unknown"
}
fmt.Fprintln(s.Stdout, user)
return nil
}
func (s *Shell) builtinHostname(_ []string) error {
h, err := os.Hostname()
if err != nil {
h = os.Getenv("COMPUTERNAME")
}
if h == "" {
h = "unknown"
}
fmt.Fprintln(s.Stdout, h)
return nil
}
func (s *Shell) builtinMapfile(args []string) error {
varName := ""
trimNewlines := false
for _, a := range args {
if a == "-t" {
trimNewlines = true
} else if !strings.HasPrefix(a, "-") {
varName = a
}
}
if varName == "" {
varName = "MAPFILE"
}
var lines []string
scanner := bufio.NewScanner(s.Stdin)
for scanner.Scan() {
line := scanner.Text()
if !trimNewlines {
line += "\n"
}
lines = append(lines, line)
}
s.setArray(varName, lines)
return nil
}
// builtinDoubleBracket implements [[ ... ]]
func (s *Shell) builtinDoubleBracket(args []string) error {
// Strip trailing ]] if present
if len(args) > 0 && args[len(args)-1] == "]]" {
args = args[:len(args)-1]
}
if !s.evalDB(args) {
return exitCodeErr{1}
}
return nil
}
// evalDB evaluates a [[ ... ]] expression.
func (s *Shell) evalDB(args []string) bool {
if len(args) == 0 {
return false
}
// 1. || — lowest precedence, scan right-to-left
for i := len(args) - 1; i >= 0; i-- {
if args[i] == "||" {
return s.evalDB(args[:i]) || s.evalDB(args[i+1:])
}
}
// 2. &&
for i := len(args) - 1; i >= 0; i-- {
if args[i] == "&&" {
return s.evalDB(args[:i]) && s.evalDB(args[i+1:])
}
}
// 3. ! prefix
if args[0] == "!" {
return !s.evalDB(args[1:])
}
// 4. Unary flags
if len(args) == 2 {
val := args[1]
switch args[0] {
case "-f":
info, err := os.Stat(val)
return err == nil && info.Mode().IsRegular()
case "-e":
_, err := os.Stat(val)
return err == nil
case "-d":
info, err := os.Stat(val)
return err == nil && info.IsDir()
case "-s":
info, err := os.Stat(val)
return err == nil && info.Size() > 0
case "-r":
f, err := os.Open(val)
if err != nil {
return false
}
f.Close()
return true
case "-w":
f, err := os.OpenFile(val, os.O_WRONLY, 0)
if err != nil {
return false
}
f.Close()
return true
case "-x":
info, err := os.Stat(val)
return err == nil && (info.Mode()&0111 != 0 || runtime.GOOS == "windows")
case "-n":
return val != ""
case "-z":
return val == ""
case "-L":
_, err := os.Lstat(val)
return err == nil
case "-v":
_, ok := s.vars[val]
if !ok {
_, ok = s.arrays[val]
}
return ok
}
}
// 5. Binary operators
if len(args) == 3 {
lhs, op, rhs := args[0], args[1], args[2]
switch op {
case "==":
matched, err := filepath.Match(rhs, lhs)
if err != nil {
return lhs == rhs
}
return matched
case "!=":
matched, err := filepath.Match(rhs, lhs)
if err != nil {
return lhs != rhs
}
return !matched
case "=~":
matched, err := regexp.MatchString(rhs, lhs)
return err == nil && matched
case "<":
return lhs < rhs
case ">":
return lhs > rhs
case "-eq":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln == rn
case "-ne":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln != rn
case "-lt":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln < rn
case "-le":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln <= rn
case "-gt":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln > rn
case "-ge":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln >= rn
}
}
// 6. Single arg truthy test
if len(args) == 1 {
return args[0] != ""
}
return false
}
// ─── Coreutils ──────────────────────────────────────────────────────────────── // ─── Coreutils ────────────────────────────────────────────────────────────────
func (s *Shell) cmdLs(args []string) error { func (s *Shell) cmdLs(args []string) error {

View File

@@ -2,6 +2,7 @@ package shell
import ( import (
"fmt" "fmt"
"path/filepath"
"strings" "strings"
) )
@@ -20,17 +21,40 @@ func (s *Shell) executeIf(block string) error {
phase := "if_cond" phase := "if_cond"
var curCond []string var curCond []string
var curBody []string var curBody []string
depth := 0 // nesting depth of if/for/while/until/case inside the body
addToBody := func(s string) {
switch phase {
case "body":
curBody = append(curBody, s)
case "else":
elseBody = append(elseBody, s)
}
}
for _, stmt := range stmts { for _, stmt := range stmts {
w := firstWord(stmt) w := firstWord(stmt)
rest := afterWord(stmt) rest := afterWord(stmt)
// When depth > 0, we're inside a nested block; all keywords are body content.
if depth > 0 {
switch w {
case "if", "for", "while", "until", "case":
depth++
case "fi", "done", "esac":
depth--
}
addToBody(stmt)
continue
}
// depth == 0: structural keywords for the outer if
switch { switch {
case w == "if" && phase == "if_cond": case w == "if" && phase == "if_cond":
if rest != "" { if rest != "" {
curCond = append(curCond, rest) curCond = append(curCond, rest)
} }
case w == "then": case w == "then" && (phase == "if_cond" || phase == "elif_cond"):
if rest != "" { if rest != "" {
curBody = append(curBody, rest) curBody = append(curBody, rest)
} }
@@ -66,8 +90,17 @@ func (s *Shell) executeIf(block string) error {
curCond = append(curCond, stmt) curCond = append(curCond, stmt)
case "body": case "body":
curBody = append(curBody, stmt) curBody = append(curBody, stmt)
// Track depth for nested blocks starting in body
switch w {
case "if", "for", "while", "until", "case":
depth++
}
case "else": case "else":
elseBody = append(elseBody, stmt) elseBody = append(elseBody, stmt)
switch w {
case "if", "for", "while", "until", "case":
depth++
}
} }
} }
} }
@@ -85,6 +118,125 @@ func (s *Shell) executeIf(block string) error {
return nil 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 // executeFor handles: for VAR in WORDS; do BODY; done
// (also: for VAR; do BODY; done — iterates positional params) // (also: for VAR; do BODY; done — iterates positional params)
func (s *Shell) executeFor(block string) error { func (s *Shell) executeFor(block string) error {
@@ -95,7 +247,8 @@ func (s *Shell) executeFor(block string) error {
// Parse "for VAR in WORDS" // Parse "for VAR in WORDS"
header := stmts[0] header := stmts[0]
fields := strings.Fields(header) // Use tokenize so array expansion works in "for x in ${arr[@]}"
fields := s.tokenize(header)
if len(fields) < 2 { if len(fields) < 2 {
return fmt.Errorf("for: bad syntax") return fmt.Errorf("for: bad syntax")
} }
@@ -110,10 +263,8 @@ func (s *Shell) executeFor(block string) error {
} }
} }
if inIdx >= 0 { if inIdx >= 0 {
for _, raw := range fields[inIdx+1:] { // Items are already expanded by tokenize
expanded := s.expandWord(raw) items = fields[inIdx+1:]
items = append(items, s.expandGlob(expanded)...)
}
} else { } else {
// for var; do ... → iterate positional params // for var; do ... → iterate positional params
items = s.args items = s.args
@@ -164,6 +315,169 @@ func (s *Shell) executeFor(block string) error {
return nil 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. // executeWhileUntil handles while/until loops.
func (s *Shell) executeWhileUntil(block string, isUntil bool) error { func (s *Shell) executeWhileUntil(block string, isUntil bool) error {
stmts := splitStatements(block) stmts := splitStatements(block)

View File

@@ -58,6 +58,12 @@ func splitPipe(input string) []string {
case c == '"' && !inSingle && parenDepth == 0: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
// Process substitution <(...): don't split | inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
case c == '$' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(': case c == '$' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(':
pendingDollar = true pendingDollar = true
current.WriteByte(c) current.WriteByte(c)
@@ -70,7 +76,10 @@ func splitPipe(input string) []string {
current.WriteByte(c) current.WriteByte(c)
case c == '|' && !inSingle && !inDouble && parenDepth == 0: case c == '|' && !inSingle && !inDouble && parenDepth == 0:
if i+1 < len(input) && input[i+1] == '|' { if i+1 < len(input) && input[i+1] == '|' {
current.WriteByte(c) // part of ||, pass through // || operator — pass both chars through
current.WriteByte(c)
current.WriteByte(input[i+1])
i++
} else { } else {
parts = append(parts, strings.TrimSpace(current.String())) parts = append(parts, strings.TrimSpace(current.String()))
current.Reset() current.Reset()
@@ -87,6 +96,63 @@ func splitPipe(input string) []string {
return parts 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 &&/||). // executeCommand executes a single command (no pipes, no &&/||).
func (s *Shell) executeCommand(input string) error { func (s *Shell) executeCommand(input string) error {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
@@ -94,12 +160,31 @@ func (s *Shell) executeCommand(input string) error {
return nil return nil
} }
// { cmd; cmd; } command group
if strings.HasPrefix(input, "{") {
end := strings.LastIndex(input, "}")
if end > 0 {
inner := strings.TrimSpace(input[1:end])
return s.Execute(inner)
}
}
// Detect array assignment NAME=(...) or NAME+=(...)
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) tokens := s.tokenize(input)
if len(tokens) == 0 { if len(tokens) == 0 {
return nil return nil
} }
cmdArgs, redirects := extractRedirects(tokens) cmdArgs, redirects := s.extractRedirects(tokens)
if len(cmdArgs) == 0 { if len(cmdArgs) == 0 {
// Pure redirection, e.g. "> file" creates/truncates file // Pure redirection, e.g. "> file" creates/truncates file
return s.withRedirects(redirects, func() error { return nil }) return s.withRedirects(redirects, func() error { return nil })
@@ -132,7 +217,7 @@ func (s *Shell) executeCommand(input string) error {
}) })
} }
func extractRedirects(tokens []string) ([]string, []redirect) { func (s *Shell) extractRedirects(tokens []string) ([]string, []redirect) {
var args []string var args []string
var redirects []redirect var redirects []redirect
@@ -194,11 +279,38 @@ func extractRedirects(tokens []string) ([]string, []redirect) {
case strings.HasPrefix(tok, ">") && len(tok) > 1: case strings.HasPrefix(tok, ">") && len(tok) > 1:
redirects = append(redirects, redirect{1, ">", tok[1:]}) redirects = append(redirects, redirect{1, ">", tok[1:]})
i++ i++
// < (stdin) // <<< 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 == "<": case tok == "<":
if i+1 < len(tokens) { if i+1 < len(tokens) {
redirects = append(redirects, redirect{0, "<", tokens[i+1]}) next := tokens[i+1]
i += 2 // 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 { } else {
i++ i++
} }
@@ -228,7 +340,11 @@ func (s *Shell) withRedirects(redirects []redirect, fn func() error) error {
} }
}() }()
for _, r := range redirects { for i, r := range redirects {
if r.dest == "/dev/null" {
redirects[i].dest = os.DevNull
r = redirects[i]
}
switch r.mode { switch r.mode {
case ">": case ">":
if r.dest == "&1" { if r.dest == "&1" {
@@ -278,6 +394,20 @@ func (s *Shell) executeExternal(cmdName string, args []string) error {
fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName) fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName)
return exitCodeErr{127} return exitCodeErr{127}
} }
// If the file is a bash/sh script, run it through our own interpreter.
// This avoids the CRLF shebang problem (#!/usr/bin/env bash\r) and lets
// us execute scripts that have no .exe extension on Windows.
if data, err := os.ReadFile(path); err == nil && isShellScript(data) {
sh := New()
sh.Stdin = s.Stdin
sh.Stdout = s.Stdout
sh.Stderr = s.Stderr
sh.SetArgs(args)
sh.SetVar("0", path)
return sh.Execute(string(data))
}
cmd := exec.Command(path, args...) cmd := exec.Command(path, args...)
cmd.Stdin = s.Stdin cmd.Stdin = s.Stdin
cmd.Stdout = s.Stdout cmd.Stdout = s.Stdout
@@ -291,12 +421,31 @@ func (s *Shell) executeExternal(cmdName string, args []string) error {
return err return err
} }
// isShellScript returns true when data begins with a #!/…bash or #!/…sh shebang.
func isShellScript(data []byte) bool {
if len(data) < 2 || data[0] != '#' || data[1] != '!' {
return false
}
nl := bytes.IndexByte(data, '\n')
if nl < 0 {
nl = len(data)
}
line := strings.TrimRight(string(data[:nl]), "\r")
for _, word := range strings.Fields(line[2:]) {
base := filepath.Base(word)
if base == "bash" || base == "sh" {
return true
}
}
return false
}
func (s *Shell) executeCommandBg(input string) error { func (s *Shell) executeCommandBg(input string) error {
tokens := s.tokenize(input) tokens := s.tokenize(input)
if len(tokens) == 0 { if len(tokens) == 0 {
return nil return nil
} }
cmdArgs, _ := extractRedirects(tokens) cmdArgs, _ := s.extractRedirects(tokens)
if len(cmdArgs) == 0 { if len(cmdArgs) == 0 {
return nil return nil
} }

View File

@@ -60,7 +60,17 @@ func (s *Shell) expandWord(word string) string {
i += 2 i += 2
} }
} else { } else {
result.WriteByte(next) // 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 i += 2
} }
} else { } else {
@@ -72,6 +82,81 @@ func (s *Shell) expandWord(word string) string {
result.WriteByte('$') result.WriteByte('$')
break 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] { switch word[i] {
case '(': case '(':
if i+1 < len(word) && word[i+1] == '(' { if i+1 < len(word) && word[i+1] == '(' {
@@ -146,7 +231,11 @@ func (s *Shell) expandWord(word string) string {
result.WriteString(s.vars["#"]) result.WriteString(s.vars["#"])
i++ i++
case '@': case '@':
result.WriteString(s.vars["@"]) if len(s.args) == 0 {
result.WriteString("\x01") // empty-expansion sentinel → 0 tokens
} else {
result.WriteString(strings.Join(s.args, "\x01"))
}
i++ i++
case '*': case '*':
result.WriteString(s.vars["*"]) result.WriteString(s.vars["*"])
@@ -172,17 +261,118 @@ func (s *Shell) expandWord(word string) string {
} }
func (s *Shell) getVar(name string) string { func (s *Shell) getVar(name string) string {
if v, ok := s.vars[name]; ok { // Resolve nameref chain
resolved := s.resolveNR(name)
if v, ok := s.vars[resolved]; ok {
return v return v
} }
return os.Getenv(name) return os.Getenv(resolved)
} }
func (s *Shell) evalVarExpr(expr string) string { func (s *Shell) evalVarExpr(expr string) string {
// ${#VAR} — string length // ${#arr[@]} or ${#arr[*]} → length of array
if strings.HasPrefix(expr, "#") { if strings.HasPrefix(expr, "#") {
return strconv.Itoa(len(s.getVar(expr[1:]))) 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} // ${VAR:-default}
if idx := strings.Index(expr, ":-"); idx >= 0 { if idx := strings.Index(expr, ":-"); idx >= 0 {
varName := expr[:idx] varName := expr[:idx]
@@ -285,7 +475,48 @@ func evalArithExpr(expr string) int {
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") { if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
return evalArithExpr(expr[1 : len(expr)-1]) return evalArithExpr(expr[1 : len(expr)-1])
} }
// Operators in precedence order (lowest first so we split on last occurrence)
// 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{"+", "-", "*", "/", "%"} { for _, op := range []string{"+", "-", "*", "/", "%"} {
if idx := findBinaryOp(expr, op); idx >= 0 { if idx := findBinaryOp(expr, op); idx >= 0 {
left := evalArithExpr(expr[:idx]) left := evalArithExpr(expr[:idx])
@@ -313,6 +544,39 @@ func evalArithExpr(expr string) int {
return 0 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 { func findBinaryOp(expr, op string) int {
depth := 0 depth := 0
for i := len(expr) - 1; i >= 0; i-- { for i := len(expr) - 1; i >= 0; i-- {
@@ -353,7 +617,7 @@ func (s *Shell) tokenize(input string) []string {
current := strings.Builder{} current := strings.Builder{}
inSingle := false inSingle := false
inDouble := false inDouble := false
parenDepth := 0 // nesting depth inside $(...) or $((...)) parenDepth := 0 // nesting depth inside $(...) or $((...))
pendingDollar := false // true after $ when next char is ( pendingDollar := false // true after $ when next char is (
wasQuoted := false wasQuoted := false
@@ -381,6 +645,17 @@ func (s *Shell) tokenize(input string) []string {
inDouble = !inDouble inDouble = !inDouble
wasQuoted = true wasQuoted = true
current.WriteByte(c) current.WriteByte(c)
// Process substitution <(...): when '<' is immediately followed by '('
// (no space between), keep the entire <(cmd args...) as one token so
// extractRedirects can run the command and redirect stdin to its output.
// The standalone '<' redirect operator always has a space after it and
// therefore becomes its own token before this case is reached.
case c == '<' && !inSingle && !inDouble && parenDepth == 0 &&
i+1 < len(input) && input[i+1] == '(':
current.WriteByte('<')
current.WriteByte('(')
parenDepth++
i++ // skip '('; outer loop will add 1 more → 2 chars consumed
case c == '$' && !inSingle && i+1 < len(input) && (input[i+1] == '(' || input[i+1] == '{'): case c == '$' && !inSingle && i+1 < len(input) && (input[i+1] == '(' || input[i+1] == '{'):
// Mark that the next ( opens a substitution — don't increment depth here // Mark that the next ( opens a substitution — don't increment depth here
if input[i+1] == '(' { if input[i+1] == '(' {
@@ -420,7 +695,7 @@ doneTokenizing:
if eqIdx := strings.Index(clean, "="); eqIdx > 0 { if eqIdx := strings.Index(clean, "="); eqIdx > 0 {
name := clean[:eqIdx] name := clean[:eqIdx]
if isValidIdentifier(name) && !strings.Contains(clean[:eqIdx], "$") { if isValidIdentifier(name) && !strings.Contains(clean[:eqIdx], "$") {
value := s.expandWord(clean[eqIdx+1:]) value := strings.ReplaceAll(s.expandWord(clean[eqIdx+1:]), "\x01", " ")
s.vars[name] = value s.vars[name] = value
os.Setenv(name, value) os.Setenv(name, value)
rawTokens = rawTokens[1:] rawTokens = rawTokens[1:]
@@ -438,6 +713,25 @@ doneTokenizing:
tok = tok[2:] tok = tok[2:]
} }
expanded := s.expandWord(tok) 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, "*?[") { if !quoted && strings.ContainsAny(expanded, "*?[") {
result = append(result, s.expandGlob(expanded)...) result = append(result, s.expandGlob(expanded)...)
} else { } else {

View File

@@ -24,6 +24,8 @@ func (e exitCodeErr) Error() string { return "" }
type Shell struct { type Shell struct {
vars map[string]string vars map[string]string
arrays map[string][]string
namerefs map[string]string
builtins map[string]func([]string) error builtins map[string]func([]string) error
funcs map[string]string // function name → body funcs map[string]string // function name → body
lastExit int lastExit int
@@ -31,15 +33,20 @@ type Shell struct {
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
args []string args []string
errexit bool
nounset bool
pipefail bool
} }
func New() *Shell { func New() *Shell {
s := &Shell{ s := &Shell{
vars: map[string]string{}, vars: map[string]string{},
funcs: map[string]string{}, arrays: map[string][]string{},
Stdin: os.Stdin, namerefs: map[string]string{},
Stdout: os.Stdout, funcs: map[string]string{},
Stderr: os.Stderr, Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
} }
s.initBuiltins() s.initBuiltins()
s.vars["SHELL"] = "bash-for-windows" s.vars["SHELL"] = "bash-for-windows"
@@ -58,6 +65,38 @@ func New() *Shell {
return s 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) { func (s *Shell) SetArgs(args []string) {
s.args = args s.args = args
s.vars["#"] = fmt.Sprintf("%d", len(args)) s.vars["#"] = fmt.Sprintf("%d", len(args))
@@ -80,8 +119,15 @@ func (s *Shell) SetVar(name, value string) {
} }
// Execute runs commands from the given input string. // Execute runs commands from the given input string.
func PreprocessForTest(input string) string { return preprocessHeredocs(input) }
func ParseBlocksForTest(input string) []string { return parseBlocks(input) }
func (s *Shell) Execute(input string) error { func (s *Shell) Execute(input string) error {
// Normalize CRLF to LF
input = strings.ReplaceAll(input, "\r\n", "\n")
input = strings.ReplaceAll(input, "\r", "\n")
input = strings.ReplaceAll(input, "\\\n", " ") input = strings.ReplaceAll(input, "\\\n", " ")
// Pre-process heredocs
input = preprocessHeredocs(input)
blocks := parseBlocks(input) blocks := parseBlocks(input)
for _, block := range blocks { for _, block := range blocks {
if err := s.executeBlock(block); err != nil { if err := s.executeBlock(block); err != nil {
@@ -119,7 +165,7 @@ func IsIncomplete(input string) bool {
for _, stmt := range stmts { for _, stmt := range stmts {
w := firstWord(stmt) w := firstWord(stmt)
switch w { switch w {
case "if", "for", "while", "until": case "if", "for", "while", "until", "case":
depth++ depth++
case "fi", "done", "esac": case "fi", "done", "esac":
depth-- depth--
@@ -162,9 +208,9 @@ func parseBlocks(input string) []string {
funcKwDepth = 0 funcKwDepth = 0
for _, p := range splitStatements(stmt[braceIdx+1:]) { for _, p := range splitStatements(stmt[braceIdx+1:]) {
switch firstWord(p) { switch firstWord(p) {
case "if", "for", "while", "until": case "if", "for", "while", "until", "case", "{":
funcKwDepth++ funcKwDepth++
case "fi", "done", "esac": case "fi", "done", "esac", "}":
funcKwDepth-- funcKwDepth--
} }
} }
@@ -182,7 +228,7 @@ func parseBlocks(input string) []string {
} }
switch w { switch w {
case "if", "for", "while", "until": case "if", "for", "while", "until", "case", "{":
kwDepth++ kwDepth++
} }
kwDepth += embeddedKwDepth(stmt) kwDepth += embeddedKwDepth(stmt)
@@ -211,9 +257,9 @@ func parseBlocks(input string) []string {
continue continue
} }
switch w { switch w {
case "if", "for", "while", "until": case "if", "for", "while", "until", "case", "{":
funcKwDepth++ funcKwDepth++
case "fi", "done", "esac": case "fi", "done", "esac", "}":
funcKwDepth-- funcKwDepth--
} }
funcKwDepth += embeddedKwDepth(stmt) funcKwDepth += embeddedKwDepth(stmt)
@@ -229,6 +275,7 @@ func parseBlocks(input string) []string {
// embeddedKwDepth returns the net depth change from keywords that appear // embeddedKwDepth returns the net depth change from keywords that appear
// after do/then/else/elif within a single statement (excluding the first word, // after do/then/else/elif within a single statement (excluding the first word,
// which is handled separately by the caller). // which is handled separately by the caller).
func EmbeddedKwDepthForTest(s string) int { return embeddedKwDepth(s) }
func embeddedKwDepth(stmt string) int { func embeddedKwDepth(stmt string) int {
words := strings.Fields(stmt) words := strings.Fields(stmt)
delta := 0 delta := 0
@@ -246,23 +293,192 @@ func embeddedKwDepth(stmt string) int {
return delta return delta
} }
// splitStatements splits input on semicolons and newlines, respecting quotes. // 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 { func splitStatements(input string) []string {
var result []string var result []string
current := strings.Builder{} current := strings.Builder{}
inSingle := false inSingle := false
inDouble := false inDouble := false
parenDepth := 0
braceDepth := 0 // { } command groups — don't split ; inside them
for i := 0; i < len(input); i++ { for i := 0; i < len(input); i++ {
c := input[i] c := input[i]
switch { switch {
case c == '\'' && !inDouble: case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle inSingle = !inSingle
current.WriteByte(c) current.WriteByte(c)
case c == '"' && !inSingle: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
case (c == ';' || c == '\n') && !inSingle && !inDouble: 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 != "" { if s := strings.TrimSpace(current.String()); s != "" {
result = append(result, s) result = append(result, s)
} }
@@ -338,21 +554,31 @@ func (s *Shell) executeBlock(block string) error {
case "if": case "if":
return s.executeIf(block) return s.executeIf(block)
case "for": 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) return s.executeFor(block)
case "while": case "while":
return s.executeWhileUntil(block, false) return s.executeWhileUntil(block, false)
case "until": case "until":
return s.executeWhileUntil(block, true) return s.executeWhileUntil(block, true)
case "case":
return s.executeCase(block)
} }
if isFuncDefStart(block) { if isFuncDefStart(block) {
return s.defineFunction(block) return s.defineFunction(block)
} }
for _, line := range strings.Split(block, "\n") { // Use splitStatements instead of strings.Split("\n") so that multi-line
line = strings.TrimSpace(line) // constructs (e.g. process substitutions spanning several lines) are kept
if line == "" || strings.HasPrefix(line, "#") { // together as a single logical unit rather than being broken apart.
for _, stmt := range splitStatements(block) {
stmt = strings.TrimSpace(stmt)
if stmt == "" || strings.HasPrefix(stmt, "#") {
continue continue
} }
if err := s.executeLine(line); err != nil { if err := s.executeLine(stmt); err != nil {
return err return err
} }
} }
@@ -385,20 +611,40 @@ func splitBySemicolon(line string) []string {
current := strings.Builder{} current := strings.Builder{}
inSingle := false inSingle := false
inDouble := false inDouble := false
parenDepth := 0 // tracks $(...) and <(...) nesting
pendingDollar := false
for i := 0; i < len(line); i++ { for i := 0; i < len(line); i++ {
c := line[i] c := line[i]
switch { switch {
case c == '\'' && !inDouble: case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle inSingle = !inSingle
current.WriteByte(c) current.WriteByte(c)
case c == '"' && !inSingle: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
case c == ';' && !inSingle && !inDouble: // Process substitution <(...): don't split ; | && || inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
// Command substitution $(...): don't split ; inside.
case c == '$' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
pendingDollar = true
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == ';' && !inSingle && !inDouble && parenDepth == 0:
parts = append(parts, current.String()) parts = append(parts, current.String())
current.Reset() current.Reset()
default: default:
pendingDollar = false
current.WriteByte(c) current.WriteByte(c)
} }
} }
@@ -418,27 +664,59 @@ func (s *Shell) executeAndOrList(line string) error {
op := "" op := ""
inSingle := false inSingle := false
inDouble := 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++ { for i := 0; i < len(line); i++ {
c := line[i] c := line[i]
switch { switch {
case c == '\'' && !inDouble: case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle inSingle = !inSingle
current.WriteByte(c) current.WriteByte(c)
case c == '"' && !inSingle: case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble inDouble = !inDouble
current.WriteByte(c) current.WriteByte(c)
case c == '&' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '&': // Process substitution <(...): don't split && || inside.
case c == '<' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
parenDepth++
current.WriteByte('<')
current.WriteByte('(')
i++
case c == '$' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '(':
pendingDollar = true
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == '[' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == '[':
dbDepth++
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}) tokens = append(tokens, tok{current.String(), op})
current.Reset() current.Reset()
op = "&&" op = "&&"
i++ i++
case c == '|' && !inSingle && !inDouble && i+1 < len(line) && line[i+1] == '|': case c == '|' && !inSingle && !inDouble && parenDepth == 0 && i+1 < len(line) && line[i+1] == '|' && dbDepth == 0:
pendingDollar = false
tokens = append(tokens, tok{current.String(), op}) tokens = append(tokens, tok{current.String(), op})
current.Reset() current.Reset()
op = "||" op = "||"
i++ i++
default: default:
pendingDollar = false
current.WriteByte(c) current.WriteByte(c)
} }
} }