Add control flow, I/O redirection, functions, coreutils, history/completion

- I/O redirection: >, >>, <, 2>, 2>&1, &>
- Job control: background & operator
- if/elif/else/fi, for/do/done, while/until loops
- Shell functions with local/declare, positional param save/restore
- Exit code propagation via exitCodeErr sentinel
- Arithmetic expansion $((expr)) with bare variable names
- Command substitution $(cmd) with pipeline support
- Glob expansion, tilde expansion, ${VAR:-default} and other forms
- Tab completion and command history via chzyer/readline
- Inline comment stripping (# outside quotes)
- Builtins: test/[, read, printf, tr, sed, cut, tail, tee, xargs,
  basename, dirname, date, sleep, uniq, sort, wc, head, grep, find,
  true, false, break, continue, return, shift, set, unset, export,
  declare/local, source, alias, jobs, command, which, env
- Bug fixes: tokenizer parenDepth double-count for $((,
  splitPipe not paren-aware (broke pipelines in $()),
  local/declare TrimLeft stripping valid var name chars,
  parseBlocks missing nested keywords after do/then/else

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cametendo
2026-05-26 12:50:06 +02:00
parent eba49c46bc
commit 8c6a2ab4c2
8 changed files with 3669 additions and 869 deletions

View File

@@ -1,66 +1,261 @@
package bash
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/cametendo/bash-for-windows/internal/shell"
"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 {
if args[0] == "-c" && len(args) > 1 {
// Execute a command string
return runCommand(strings.Join(args[1:], " "))
}
// Run a script file
return runFile(args[0])
}
return interactive()
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 runCommand(cmd string) error {
sh := shell.New()
return sh.Execute(cmd)
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(append([]string{path}, args...))
sh.SetVar("0", path)
return sh.Execute(string(data))
}
func runFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("%s: %v", path, err)
}
sh := shell.New()
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()
reader := bufio.NewReader(os.Stdin)
fmt.Println("bash-for-windows v1.0.0")
for {
fmt.Print("bash$ ")
input, err := reader.ReadString('\n')
if err != nil {
break
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
if input == "exit" {
break
}
if err := sh.Execute(input); err != nil {
fmt.Fprintf(os.Stderr, "bash: %v\n", err)
}
}
return nil
sh := shell.New()
// Build completer
completer := readline.NewPrefixCompleter(
readline.PcItemDynamic(func(line string) []string {
return dynamicComplete(sh, line)
}),
)
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
}
// 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 += "/"
}
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
}