445 lines
11 KiB
Go
445 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/chzyer/readline"
|
|
"github.com/google/shlex"
|
|
)
|
|
|
|
// ErrNoPath is returned when 'cd' was called without a second argument.
|
|
var ErrNoPath = errors.New("path required")
|
|
|
|
// previousDir tracks the previous working directory for 'cd -' support
|
|
var previousDir string
|
|
|
|
// getHomeDir returns the user's home directory
|
|
func getHomeDir() string {
|
|
usr, err := user.Current()
|
|
if err != nil {
|
|
// Fallback to HOME environment variable
|
|
return os.Getenv("HOME")
|
|
}
|
|
return usr.HomeDir
|
|
}
|
|
|
|
// expandTilde expands ~ to the home directory and ~user to user's home
|
|
func expandTilde(path string) string {
|
|
if len(path) == 0 {
|
|
return path
|
|
}
|
|
|
|
// Handle ~ at the start
|
|
if strings.HasPrefix(path, "~") {
|
|
home := getHomeDir()
|
|
if home == "" {
|
|
return path // Can't expand, return as-is
|
|
}
|
|
|
|
// Handle ~ (just home)
|
|
if len(path) == 1 {
|
|
return home
|
|
}
|
|
|
|
// Handle ~/path or ~user/path
|
|
// For now, just handle ~/path
|
|
if path[1] == '/' {
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// expandEnv expands environment variables in a string
|
|
// Supports $VAR, ${VAR}, and ${VAR:-default} syntax
|
|
func expandEnv(s string) string {
|
|
// Use os.ExpandEnv for simple ${VAR} and $VAR expansion
|
|
return os.ExpandEnv(s)
|
|
}
|
|
|
|
// getPrompt returns a dynamic prompt string like "user@host:dir $"
|
|
func getPrompt() string {
|
|
// Get current working directory
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
wd = "?"
|
|
}
|
|
|
|
// Get username
|
|
usr, err := user.Current()
|
|
username := "?"
|
|
if err == nil && usr.Username != "" {
|
|
username = usr.Username
|
|
}
|
|
|
|
// Get hostname
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "unknown"
|
|
}
|
|
|
|
// Shorten the path for display
|
|
// Use only the last component if in home directory
|
|
home := getHomeDir()
|
|
if home != "" && strings.HasPrefix(wd, home) {
|
|
// Replace home with ~
|
|
displayPath := "~" + strings.TrimPrefix(wd, home)
|
|
return fmt.Sprintf("%s@%s:%s $ ", username, hostname, displayPath)
|
|
}
|
|
|
|
return fmt.Sprintf("%s@%s:%s $ ", username, hostname, wd)
|
|
}
|
|
|
|
// shellCompleter implements readline.AutoCompleter for contextual completion
|
|
type shellCompleter struct {
|
|
commands []string
|
|
}
|
|
|
|
func newShellCompleter() *shellCompleter {
|
|
return &shellCompleter{
|
|
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"},
|
|
}
|
|
}
|
|
|
|
// Do implements readline.AutoCompleter interface.
|
|
// readline expects the candidates to contain only the text that still has to
|
|
// be inserted after the cursor, and the returned length to be the number of
|
|
// runes of the current word that are already typed.
|
|
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
|
word, start, quote := currentWord(string(line[:pos]))
|
|
isFirstWord := strings.TrimSpace(string(line[:start])) == ""
|
|
|
|
// Plain "~" -> "~/"
|
|
if word == "~" {
|
|
return [][]rune{[]rune("/")}, 1
|
|
}
|
|
|
|
if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") {
|
|
// Complete commands
|
|
var candidates [][]rune
|
|
seen := make(map[string]bool)
|
|
for _, cmd := range append(c.commands, pathExecutables(word)...) {
|
|
if strings.HasPrefix(cmd, word) && !seen[cmd] {
|
|
seen[cmd] = true
|
|
candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" "))
|
|
}
|
|
}
|
|
return candidates, len([]rune(word))
|
|
}
|
|
|
|
// Complete file paths; only the part after the last slash is completed
|
|
pattern := word[strings.LastIndex(word, "/")+1:]
|
|
matches := completeFilePath(string(line[:pos]), word)
|
|
var candidates [][]rune
|
|
for _, m := range matches {
|
|
rest := m[len(pattern):]
|
|
if quote == 0 {
|
|
rest = escapeWord(rest)
|
|
}
|
|
candidates = append(candidates, []rune(rest))
|
|
}
|
|
return candidates, len([]rune(pattern))
|
|
}
|
|
|
|
// pathExecutables returns the names of all executables in $PATH starting
|
|
// with prefix
|
|
func pathExecutables(prefix string) []string {
|
|
var names []string
|
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
// Follow symlinks, only regular files with an executable bit count
|
|
info, err := os.Stat(filepath.Join(dir, name))
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// currentWord returns the (unescaped, unquoted) word the cursor is in, the
|
|
// byte offset where it starts in s and the quote character that is still open
|
|
// (0 if none).
|
|
func currentWord(s string) (word string, start int, quote byte) {
|
|
var b strings.Builder
|
|
escaped := false
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch {
|
|
case escaped:
|
|
b.WriteByte(ch)
|
|
escaped = false
|
|
case ch == '\\' && quote != '\'':
|
|
escaped = true
|
|
case quote != 0:
|
|
if ch == quote {
|
|
quote = 0
|
|
} else {
|
|
b.WriteByte(ch)
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == ' ' || ch == '\t':
|
|
b.Reset()
|
|
start = i + 1
|
|
default:
|
|
b.WriteByte(ch)
|
|
}
|
|
}
|
|
return b.String(), start, quote
|
|
}
|
|
|
|
// escapeWord escapes characters that would otherwise be interpreted by shlex
|
|
func escapeWord(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if strings.ContainsRune(" \t\\'\"", r) {
|
|
b.WriteByte('\\')
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// completeFilePath handles file path completion.
|
|
// prefix is the unquoted path typed so far. It returns the names (with a
|
|
// trailing "/" for directories) of all entries in the directory part of
|
|
// prefix that start with the part after the last slash.
|
|
func completeFilePath(line, prefix string) []string {
|
|
// Split textually so that "." or "~" are not normalized away
|
|
slash := strings.LastIndex(prefix, "/")
|
|
dir, pattern := prefix[:slash+1], prefix[slash+1:]
|
|
|
|
switch {
|
|
case dir == "":
|
|
dir = "."
|
|
case strings.HasPrefix(dir, "~"):
|
|
dir = expandTilde(dir)
|
|
}
|
|
|
|
// Read the directory contents (relative paths resolve against the cwd)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Collect matching entries
|
|
var matches []string
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, pattern) {
|
|
continue
|
|
}
|
|
// Hide dotfiles unless explicitly asked for
|
|
if strings.HasPrefix(name, ".") && !strings.HasPrefix(pattern, ".") {
|
|
continue
|
|
}
|
|
// Follow symlinks to decide whether this is a directory
|
|
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
|
name += "/"
|
|
}
|
|
matches = append(matches, name)
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
func main() {
|
|
// Create readline instance
|
|
l, err := readline.NewEx(&readline.Config{
|
|
Prompt: getPrompt(),
|
|
HistoryFile: "./gosh_history",
|
|
AutoComplete: newShellCompleter(),
|
|
InterruptPrompt: "^C",
|
|
EOFPrompt: "exit",
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Error initializing readline:", err)
|
|
os.Exit(1)
|
|
}
|
|
defer l.Close()
|
|
|
|
// Main loop
|
|
for {
|
|
// Update prompt dynamically (in case directory changed)
|
|
l.SetPrompt(getPrompt())
|
|
|
|
line, err := l.Readline()
|
|
if err != nil {
|
|
// Handle EOF (Ctrl+D)
|
|
if err == io.EOF || err == readline.ErrInterrupt {
|
|
fmt.Println()
|
|
break
|
|
}
|
|
fmt.Fprintln(os.Stderr, "Error reading input:", err)
|
|
continue
|
|
}
|
|
|
|
// Skip empty input
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Add to history
|
|
l.SaveHistory(line)
|
|
|
|
// Execute the input
|
|
if err := execInput(line); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func execInput(input string) error {
|
|
// Split the input separate the command and the arguments.
|
|
args, err := shlex.Split(input)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid input: %w", err)
|
|
}
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Expand environment variables in all arguments
|
|
for i, arg := range args {
|
|
args[i] = expandEnv(arg)
|
|
}
|
|
|
|
// Expand tilde in all arguments
|
|
for i, arg := range args {
|
|
args[i] = expandTilde(arg)
|
|
}
|
|
|
|
// Check for built-in commands.
|
|
switch args[0] {
|
|
case "cd":
|
|
// Save current directory before changing (for 'cd -')
|
|
currentDir, _ := os.Getwd()
|
|
|
|
// Handle different cd cases
|
|
if len(args) < 2 {
|
|
// cd with no args: go to home directory
|
|
previousDir = currentDir
|
|
return os.Chdir(getHomeDir())
|
|
}
|
|
|
|
path := args[1]
|
|
|
|
// Handle 'cd -' : go to previous directory
|
|
if path == "-" {
|
|
if previousDir == "" {
|
|
return fmt.Errorf("no previous directory")
|
|
}
|
|
// Save current directory as the new previous
|
|
// so that next 'cd -' will come back here
|
|
prevDir := previousDir
|
|
previousDir = currentDir
|
|
return os.Chdir(prevDir)
|
|
}
|
|
|
|
// Expand tilde in the path
|
|
path = expandTilde(path)
|
|
|
|
// Save current directory as previous before changing
|
|
previousDir = currentDir
|
|
|
|
// Change the directory and return the error.
|
|
return os.Chdir(path)
|
|
case "pwd":
|
|
wd, err := os.Getwd()
|
|
fmt.Println(wd)
|
|
return err
|
|
case "exit":
|
|
// exit with optional status code
|
|
if len(args) > 1 {
|
|
var exitCode int
|
|
fmt.Sscanf(args[1], "%d", &exitCode)
|
|
os.Exit(exitCode)
|
|
}
|
|
os.Exit(0)
|
|
case "quit":
|
|
// quit with optional status code
|
|
if len(args) > 1 {
|
|
var exitCode int
|
|
fmt.Sscanf(args[1], "%d", &exitCode)
|
|
os.Exit(exitCode)
|
|
}
|
|
os.Exit(0)
|
|
case "set":
|
|
// set VAR=value - set environment variable
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("usage: set VAR=value")
|
|
}
|
|
// Parse VAR=value
|
|
parts := strings.SplitN(args[1], "=", 2)
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("usage: set VAR=value")
|
|
}
|
|
os.Setenv(parts[0], parts[1])
|
|
return nil
|
|
case "export":
|
|
// export VAR=value - same as set but also marks for child processes
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("usage: export VAR=value")
|
|
}
|
|
// Parse VAR=value
|
|
parts := strings.SplitN(args[1], "=", 2)
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("usage: export VAR=value")
|
|
}
|
|
os.Setenv(parts[0], parts[1])
|
|
return nil
|
|
case "help":
|
|
fmt.Println(`gosh - Go Shell
|
|
|
|
Built-in commands:
|
|
cd [dir] - Change directory (cd with no args goes to home)
|
|
pwd - Print working directory
|
|
echo [args] - Echo arguments
|
|
exit [code] - Exit shell with optional status code
|
|
quit [code] - Quit shell with optional status code
|
|
set VAR=val - Set environment variable
|
|
export VAR=val - Export environment variable
|
|
help - Show this help
|
|
|
|
Features:
|
|
- Argument parsing with quotes and escapes
|
|
- Ctrl+D to exit, Ctrl+C to cancel
|
|
- Command history (up/down arrows)
|
|
- Tab completion for commands and file paths
|
|
- Tilde expansion (~, ~/path)
|
|
- Environment variable expansion ($VAR, ${VAR})`)
|
|
return nil
|
|
}
|
|
|
|
// Prepare the command to execute.
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
|
|
// Set the correct output device.
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdout = os.Stdout
|
|
|
|
// Execute the command return the error.
|
|
return cmd.Run()
|
|
}
|