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:
299
cmd/bash/main.go
299
cmd/bash/main.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user