first commit
This commit is contained in:
commit
6b177e15b3
4 files changed
+1259
No files matched your search
@@ -0,0 +1,455 @@
|
||||
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
|
||||
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
||||
str := string(line[:pos])
|
||||
|
||||
// Find the start of the current word being completed
|
||||
lastSpace := strings.LastIndex(str, " ")
|
||||
var prefixStart int
|
||||
var prefix string
|
||||
var isFirstWord bool
|
||||
|
||||
if lastSpace == -1 {
|
||||
// No space found - could be first word (command) or a path without space like ls/file
|
||||
prefix = str
|
||||
prefixStart = 0
|
||||
// Check if it contains a slash - if so, it's a path, not a command
|
||||
if strings.Contains(prefix, "/") || strings.HasPrefix(prefix, "~") {
|
||||
isFirstWord = false
|
||||
} else {
|
||||
isFirstWord = true
|
||||
}
|
||||
} else {
|
||||
prefix = str[lastSpace+1:]
|
||||
prefixStart = lastSpace + 1
|
||||
isFirstWord = false
|
||||
}
|
||||
|
||||
// Remove surrounding quotes from the prefix for matching
|
||||
unquotedPrefix := strings.Trim(prefix, `"'`)
|
||||
|
||||
if isFirstWord {
|
||||
// Complete commands
|
||||
var candidates [][]rune
|
||||
for _, cmd := range c.commands {
|
||||
if strings.HasPrefix(cmd, unquotedPrefix) {
|
||||
candidates = append(candidates, []rune(cmd))
|
||||
}
|
||||
}
|
||||
return candidates, prefixStart
|
||||
}
|
||||
|
||||
// Complete file paths
|
||||
matches := completeFilePath(str, unquotedPrefix)
|
||||
if len(matches) == 0 {
|
||||
return nil, prefixStart
|
||||
}
|
||||
|
||||
// Build candidates: for each match, construct the full replacement
|
||||
candidates := make([][]rune, len(matches))
|
||||
for i, m := range matches {
|
||||
var fullPath string
|
||||
if strings.HasSuffix(prefix, "/") {
|
||||
// If prefix ends with /, just append the match
|
||||
fullPath = prefix + m
|
||||
} else if strings.HasPrefix(prefix, "/") {
|
||||
// Absolute path: /usr/lo + local/ -> /usr/local/
|
||||
fullPath = filepath.Join(filepath.Dir(prefix), m)
|
||||
} else if strings.HasPrefix(prefix, "~") {
|
||||
// Tilde path: ~/Doc + uments/ -> ~/Documents/
|
||||
fullPath = filepath.Join(prefix, m)
|
||||
} else {
|
||||
// Relative path
|
||||
fullPath = prefix + m
|
||||
}
|
||||
candidates[i] = []rune(fullPath)
|
||||
}
|
||||
return candidates, prefixStart
|
||||
}
|
||||
|
||||
// completeFilePath handles file path completion
|
||||
// This is exported for testing purposes
|
||||
func completeFilePath(line, prefix string) []string {
|
||||
// Get the current working directory
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine the directory to search and the pattern to match
|
||||
var dir string
|
||||
var pattern string
|
||||
|
||||
// Check if prefix ends with / - if so, we're completing inside that directory
|
||||
endsWithSlash := strings.HasSuffix(prefix, "/")
|
||||
|
||||
// Handle absolute paths
|
||||
if strings.HasPrefix(prefix, "/") {
|
||||
if endsWithSlash {
|
||||
// prefix is like "/path/to/dir/" - complete inside dir
|
||||
dir = prefix
|
||||
pattern = ""
|
||||
} else {
|
||||
// prefix is like "/path/to/dir" or "/path/to/file_"
|
||||
dir = filepath.Dir(prefix)
|
||||
pattern = filepath.Base(prefix)
|
||||
}
|
||||
} else if strings.HasPrefix(prefix, "~") {
|
||||
// Handle tilde expansion
|
||||
home := getHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
// Expand the tilde path
|
||||
fullPrefix := expandTilde(prefix)
|
||||
if endsWithSlash {
|
||||
dir = fullPrefix
|
||||
pattern = ""
|
||||
} else {
|
||||
dir = filepath.Dir(fullPrefix)
|
||||
pattern = filepath.Base(prefix)
|
||||
}
|
||||
} else {
|
||||
// Relative path - join with current directory
|
||||
if endsWithSlash {
|
||||
// prefix is like "subdir/" - complete inside subdir
|
||||
fullPrefix := filepath.Join(wd, prefix)
|
||||
dir = fullPrefix
|
||||
pattern = ""
|
||||
} else {
|
||||
fullPrefix := filepath.Join(wd, prefix)
|
||||
dir = filepath.Dir(fullPrefix)
|
||||
pattern = filepath.Base(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Read the directory contents
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect matching entries
|
||||
var matches []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if pattern == "" || strings.HasPrefix(name, pattern) {
|
||||
// Determine if this is a directory
|
||||
var suffix string
|
||||
if entry.IsDir() {
|
||||
suffix = "/"
|
||||
}
|
||||
|
||||
// Build the completion - return only the part to be appended
|
||||
// For paths with trailing slash, return just the matched name
|
||||
// For partial paths without trailing slash, return the full matching path part
|
||||
var completion string
|
||||
if endsWithSlash {
|
||||
// Prefix ends with /, so just append the matched name + suffix
|
||||
completion = name + suffix
|
||||
} else if strings.HasPrefix(prefix, "/") {
|
||||
// Absolute path without trailing slash - return remaining part
|
||||
completion = name + suffix
|
||||
} else if strings.HasPrefix(prefix, "~") {
|
||||
// Tilde path - return remaining part
|
||||
completion = name + suffix
|
||||
} else {
|
||||
// Relative path
|
||||
completion = name + suffix
|
||||
}
|
||||
|
||||
matches = append(matches, completion)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user