- 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>
262 lines
5.6 KiB
Go
262 lines
5.6 KiB
Go
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(append([]string{path}, 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()
|
|
|
|
// 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
|
|
}
|