Files
bash-for-windows/cmd/bash/main.go
2026-05-31 21:49:13 +02:00

307 lines
6.9 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(args)
sh.SetVar("0", path)
return sh.Execute(string(data))
}
func historyFile() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
// On Windows this lands in %USERPROFILE%\.bash_history
return filepath.Join(home, ".bash_history")
}
func interactive() error {
sh := shell.New()
completer := &shellCompleter{sh: sh}
rl, err := readline.NewEx(&readline.Config{
HistoryFile: historyFile(),
AutoComplete: completer,
InterruptPrompt: "^C",
EOFPrompt: "exit",
HistorySearchFold: true,
FuncFilterInputRune: filterInput,
})
if err != nil {
// Fall back to dumb interactive mode
return interactiveDumb(sh)
}
defer rl.Close()
fmt.Fprintln(os.Stdout, "bash-for-windows v2.0.0 (type 'exit' or Ctrl+D to quit)")
var multiLine strings.Builder
for {
prompt := buildPrompt(sh)
if multiLine.Len() > 0 {
prompt = "> "
}
rl.SetPrompt(prompt)
line, err := rl.Readline()
if err != nil {
if err.Error() == "Interrupt" {
// Ctrl+C: abort current multi-line input
multiLine.Reset()
fmt.Fprintln(os.Stdout)
continue
}
break // EOF
}
if multiLine.Len() > 0 {
multiLine.WriteString("\n")
}
multiLine.WriteString(line)
input := multiLine.String()
if shell.IsIncomplete(input) {
continue // wait for more input
}
multiLine.Reset()
input = strings.TrimSpace(input)
if input == "" {
continue
}
if err := sh.Execute(input); err != nil {
fmt.Fprintf(os.Stderr, "bash: %v\n", err)
}
}
return nil
}
// interactiveDumb is a fallback that doesn't need readline.
func interactiveDumb(sh *shell.Shell) error {
fmt.Fprintln(os.Stdout, "bash-for-windows v2.0.0")
var multiLine strings.Builder
buf := make([]byte, 4096)
for {
prompt := buildPrompt(sh)
if multiLine.Len() > 0 {
prompt = "> "
}
fmt.Fprint(os.Stdout, prompt)
n, err := os.Stdin.Read(buf)
if n > 0 {
chunk := string(buf[:n])
if multiLine.Len() > 0 {
multiLine.WriteString("\n")
}
multiLine.WriteString(strings.TrimRight(chunk, "\r\n"))
input := multiLine.String()
if shell.IsIncomplete(input) {
continue
}
multiLine.Reset()
input = strings.TrimSpace(input)
if input == "" {
continue
}
if execErr := sh.Execute(input); execErr != nil {
fmt.Fprintf(os.Stderr, "bash: %v\n", execErr)
}
}
if err != nil {
break
}
}
return nil
}
func buildPrompt(sh *shell.Shell) string {
pwd, _ := os.Getwd()
home, _ := os.UserHomeDir()
if home != "" && strings.HasPrefix(pwd, home) {
pwd = "~" + pwd[len(home):]
}
// Show exit code in prompt if non-zero
exitCode := sh.GetVar("?")
suffix := "$ "
if exitCode != "0" && exitCode != "" {
suffix = "[" + exitCode + "]$ "
}
return pwd + suffix
}
// shellCompleter implements readline.AutoCompleter.
// readline's built-in PrefixCompleter matches candidates against the full
// line, so "C:\workspace" never matches "cd C:\w". This implementation
// extracts the last token and returns only the suffix to append.
type shellCompleter struct{ sh *shell.Shell }
func (c *shellCompleter) Do(line []rune, pos int) (newLine [][]rune, offset int) {
lineStr := string(line[:pos])
last := lastToken(lineStr)
for _, comp := range dynamicComplete(c.sh, lineStr) {
if !strings.HasPrefix(comp, last) {
continue
}
suffix := []rune(comp[len(last):])
// Add a trailing space for non-directory completions.
if len(suffix) > 0 && suffix[len(suffix)-1] != '/' && suffix[len(suffix)-1] != '\\' {
suffix = append(suffix, ' ')
}
newLine = append(newLine, suffix)
}
return newLine, len([]rune(last))
}
// lastToken returns the last whitespace-delimited token from s,
// respecting single and double quotes.
func lastToken(s string) string {
if strings.HasSuffix(s, " ") || strings.HasSuffix(s, "\t") {
return ""
}
inSingle, inDouble := false, false
start := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case ' ', '\t':
if !inSingle && !inDouble {
start = i + 1
}
}
}
return s[start:]
}
// dynamicComplete provides tab completion for commands and paths.
func dynamicComplete(sh *shell.Shell, line string) []string {
line = strings.TrimLeft(line, " \t")
var completions []string
// Check if we're completing the first word (command) or an argument (path)
words := strings.Fields(line)
completingCommand := len(words) == 0 || (len(words) == 1 && !strings.HasSuffix(line, " "))
if completingCommand {
prefix := ""
if len(words) == 1 {
prefix = words[0]
}
// Builtin/function names — access via the shell instance
// (we can't iterate unexported fields, so use a public method)
for _, name := range sh.BuiltinNames() {
if strings.HasPrefix(name, prefix) {
completions = append(completions, name)
}
}
// PATH executables
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
// Strip .exe on completion display
name = strings.TrimSuffix(name, ".exe")
if strings.HasPrefix(name, prefix) {
completions = append(completions, name)
}
}
}
} else {
// Path completion
prefix := ""
if len(words) > 0 {
prefix = words[len(words)-1]
if strings.HasSuffix(line, " ") {
prefix = ""
}
}
dir := filepath.Dir(prefix)
base := filepath.Base(prefix)
if prefix == "" || strings.HasSuffix(prefix, "/") || strings.HasSuffix(prefix, "\\") {
dir = prefix
base = ""
}
entries, err := os.ReadDir(dir)
if err == nil {
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, base) {
p := filepath.Join(dir, name)
if e.IsDir() {
p += string(filepath.Separator)
}
completions = append(completions, p)
}
}
}
}
return completions
}
func filterInput(r rune) (rune, bool) {
// Block Ctrl+Z (26) — on Windows this would suspend; we handle it gracefully
if r == 26 {
return r, false
}
return r, true
}