Files
bash-for-windows/internal/shell/builtins.go
2026-05-28 21:00:34 +02:00

2224 lines
44 KiB
Go

package shell
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
var aliases = make(map[string]string)
func (s *Shell) initBuiltins() {
s.builtins = map[string]func([]string) error{
// Shell builtins
"cd": s.builtinCd,
"pwd": s.builtinPwd,
"echo": s.builtinEcho,
"exit": s.builtinExit,
"export": s.builtinExport,
"source": s.builtinSource,
".": s.builtinSource,
"alias": s.builtinAlias,
"unalias": s.builtinUnalias,
"type": s.builtinType,
"test": s.builtinTest,
"[": s.builtinTest,
"[[": s.builtinDoubleBracket,
"read": s.builtinRead,
"printf": s.builtinPrintf,
"true": s.builtinTrue,
"false": s.builtinFalse,
"set": s.builtinSet,
"unset": s.builtinUnset,
"env": s.builtinEnv,
"which": s.builtinWhich,
"return": s.builtinReturn,
"break": s.builtinBreak,
"continue": s.builtinContinue,
"shift": s.builtinShift,
"declare": s.builtinDeclare,
"local": s.builtinDeclare,
"command": s.builtinCommand,
"jobs": s.builtinJobs,
"disown": s.builtinDisown,
"mktemp": s.builtinMktemp,
"uname": s.builtinUname,
"whoami": s.builtinWhoami,
"hostname": s.builtinHostname,
"mapfile": s.builtinMapfile,
"readarray": s.builtinMapfile,
// Coreutils
"ls": s.cmdLs,
"cat": s.cmdCat,
"grep": s.cmdGrep,
"head": s.cmdHead,
"tail": s.cmdTail,
"sort": s.cmdSort,
"wc": s.cmdWc,
"find": s.cmdFind,
"cp": s.cmdCp,
"mv": s.cmdMv,
"rm": s.cmdRm,
"mkdir": s.cmdMkdir,
"touch": s.cmdTouch,
"clear": s.cmdClear,
"cut": s.cmdCut,
"tr": s.cmdTr,
"uniq": s.cmdUniq,
"tee": s.cmdTee,
"date": s.cmdDate,
"sleep": s.cmdSleep,
"basename": s.cmdBasename,
"dirname": s.cmdDirname,
"sed": s.cmdSed,
"xargs": s.cmdXargs,
}
}
// ─── Shell builtins ───────────────────────────────────────────────────────────
func (s *Shell) builtinCd(args []string) error {
path := ""
if len(args) > 0 {
path = args[0]
} else {
path = s.GetVar("HOME")
if path == "" {
path = os.Getenv("USERPROFILE")
}
}
path = s.expandWord(path)
if !filepath.IsAbs(path) {
pwd, _ := os.Getwd()
path = filepath.Join(pwd, path)
}
if err := os.Chdir(path); err != nil {
return fmt.Errorf("cd: %s: %v", path, err)
}
s.vars["PWD"], _ = os.Getwd()
return nil
}
func (s *Shell) builtinPwd(args []string) error {
pwd, err := os.Getwd()
if err != nil {
return err
}
fmt.Fprintln(s.Stdout, pwd)
return nil
}
func (s *Shell) builtinEcho(args []string) error {
noNewline := false
escape := false
var parts []string
for _, arg := range args {
switch arg {
case "-n":
noNewline = true
case "-e":
escape = true
case "-E":
escape = false
case "-ne", "-en":
noNewline = true
escape = true
default:
if escape {
arg = strings.ReplaceAll(arg, `\n`, "\n")
arg = strings.ReplaceAll(arg, `\t`, "\t")
arg = strings.ReplaceAll(arg, `\r`, "\r")
arg = strings.ReplaceAll(arg, `\\`, "\\")
arg = strings.ReplaceAll(arg, `\a`, "\a")
arg = strings.ReplaceAll(arg, `\b`, "\b")
}
parts = append(parts, arg)
}
}
line := strings.Join(parts, " ")
if noNewline {
fmt.Fprint(s.Stdout, line)
} else {
fmt.Fprintln(s.Stdout, line)
}
return nil
}
func (s *Shell) builtinExit(args []string) error {
code := 0
if len(args) > 0 {
fmt.Sscanf(args[0], "%d", &code)
}
os.Exit(code)
return nil
}
func (s *Shell) builtinExport(args []string) error {
if len(args) == 0 {
for _, env := range os.Environ() {
fmt.Fprintln(s.Stdout, "export "+env)
}
return nil
}
for _, arg := range args {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
s.vars[parts[0]] = parts[1]
os.Setenv(parts[0], parts[1])
} else if len(parts) == 1 {
if val, ok := s.vars[parts[0]]; ok {
os.Setenv(parts[0], val)
}
}
}
return nil
}
func (s *Shell) builtinSource(args []string) error {
if len(args) == 0 {
return fmt.Errorf("source: filename argument required")
}
data, err := os.ReadFile(args[0])
if err != nil {
return fmt.Errorf("source: %v", err)
}
// Pass remaining args as positional params to sourced script
if len(args) > 1 {
s.SetArgs(args[1:])
}
return s.Execute(string(data))
}
func (s *Shell) builtinAlias(args []string) error {
if len(args) == 0 {
names := make([]string, 0, len(aliases))
for name := range aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Fprintf(s.Stdout, "alias %s='%s'\n", name, aliases[name])
}
return nil
}
for _, arg := range args {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
aliases[parts[0]] = strings.Trim(parts[1], "'\"")
} else {
if val, ok := aliases[parts[0]]; ok {
fmt.Fprintf(s.Stdout, "alias %s='%s'\n", parts[0], val)
}
}
}
return nil
}
func (s *Shell) builtinUnalias(args []string) error {
for _, arg := range args {
if arg == "-a" {
aliases = make(map[string]string)
return nil
}
delete(aliases, arg)
}
return nil
}
func (s *Shell) builtinType(args []string) error {
for _, arg := range args {
if _, ok := s.builtins[arg]; ok {
fmt.Fprintf(s.Stdout, "%s is a shell builtin\n", arg)
} else if val, ok := aliases[arg]; ok {
fmt.Fprintf(s.Stdout, "%s is aliased to `%s`\n", arg, val)
} else if _, ok := s.funcs[arg]; ok {
fmt.Fprintf(s.Stdout, "%s is a function\n", arg)
} else if path := findExecutable(arg); path != "" {
fmt.Fprintf(s.Stdout, "%s is %s\n", arg, path)
} else {
fmt.Fprintf(s.Stdout, "%s: not found\n", arg)
}
}
return nil
}
func (s *Shell) builtinTest(args []string) error {
// Strip trailing ] when invoked as [
if len(args) > 0 && args[len(args)-1] == "]" {
args = args[:len(args)-1]
}
if s.evalTest(args) {
return nil
}
return exitCodeErr{1}
}
func (s *Shell) evalTest(args []string) bool {
if len(args) == 0 {
return false
}
// ! negation
if args[0] == "!" {
return !s.evalTest(args[1:])
}
// Compound: -a and -o
for i, arg := range args {
if arg == "-a" {
return s.evalTest(args[:i]) && s.evalTest(args[i+1:])
}
if arg == "-o" {
return s.evalTest(args[:i]) || s.evalTest(args[i+1:])
}
}
// Unary
if len(args) == 2 {
val := args[1]
switch args[0] {
case "-f":
info, err := os.Stat(val)
return err == nil && info.Mode().IsRegular()
case "-d":
info, err := os.Stat(val)
return err == nil && info.IsDir()
case "-e":
_, err := os.Stat(val)
return err == nil
case "-L", "-h":
_, err := os.Lstat(val)
return err == nil
case "-r":
f, err := os.Open(val)
if err != nil {
return false
}
f.Close()
return true
case "-w":
f, err := os.OpenFile(val, os.O_WRONLY, 0)
if err != nil {
return false
}
f.Close()
return true
case "-x":
info, err := os.Stat(val)
return err == nil && (info.Mode()&0111 != 0 || runtime.GOOS == "windows")
case "-s":
info, err := os.Stat(val)
return err == nil && info.Size() > 0
case "-z":
return val == ""
case "-n":
return val != ""
}
}
// Binary
if len(args) == 3 {
lhs, op, rhs := args[0], args[1], args[2]
switch op {
case "=", "==":
return lhs == rhs
case "!=":
return lhs != rhs
case "<":
return lhs < rhs
case ">":
return lhs > rhs
case "-eq":
return toInt(lhs) == toInt(rhs)
case "-ne":
return toInt(lhs) != toInt(rhs)
case "-lt":
return toInt(lhs) < toInt(rhs)
case "-le":
return toInt(lhs) <= toInt(rhs)
case "-gt":
return toInt(lhs) > toInt(rhs)
case "-ge":
return toInt(lhs) >= toInt(rhs)
case "-ef":
// Same file
i1, e1 := os.Stat(lhs)
i2, e2 := os.Stat(rhs)
return e1 == nil && e2 == nil && os.SameFile(i1, i2)
case "-nt":
i1, e1 := os.Stat(lhs)
i2, e2 := os.Stat(rhs)
return e1 == nil && e2 == nil && i1.ModTime().After(i2.ModTime())
case "-ot":
i1, e1 := os.Stat(lhs)
i2, e2 := os.Stat(rhs)
return e1 == nil && e2 == nil && i1.ModTime().Before(i2.ModTime())
}
}
// Single arg: true if non-empty
if len(args) == 1 {
return args[0] != ""
}
return false
}
func toInt(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
func (s *Shell) builtinRead(args []string) error {
prompt := ""
silent := false
rawMode := false
varNames := []string{"REPLY"}
i := 0
for i < len(args) {
switch args[i] {
case "-r":
rawMode = true
case "-s":
silent = true
case "-p":
i++
if i < len(args) {
prompt = args[i]
}
default:
if !strings.HasPrefix(args[i], "-") {
varNames = args[i:]
i = len(args)
continue
}
}
i++
}
_ = silent // terminal raw mode for passwords would need platform code
if prompt != "" {
fmt.Fprint(s.Stderr, prompt)
}
reader := bufio.NewReader(s.Stdin)
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return err
}
line = strings.TrimRight(line, "\r\n")
if !rawMode {
line = strings.ReplaceAll(line, "\\\n", " ")
}
if len(varNames) == 1 {
s.vars[varNames[0]] = line
} else {
parts := strings.Fields(line)
for j, name := range varNames {
if j < len(parts) {
if j == len(varNames)-1 {
s.vars[name] = strings.Join(parts[j:], " ")
} else {
s.vars[name] = parts[j]
}
} else {
s.vars[name] = ""
}
}
}
return nil
}
func (s *Shell) builtinPrintf(args []string) error {
if len(args) == 0 {
return nil
}
format := args[0]
fmtArgs := args[1:]
var result strings.Builder
argIdx := 0
i := 0
for i < len(format) {
if format[i] == '%' && i+1 < len(format) {
i++
// Optional width/precision
specStart := i
for i < len(format) && (format[i] == '-' || format[i] == '0' || (format[i] >= '1' && format[i] <= '9') || format[i] == '.') {
i++
}
spec := format[specStart:i]
if i >= len(format) {
break
}
arg := ""
if argIdx < len(fmtArgs) {
arg = fmtArgs[argIdx]
argIdx++
}
switch format[i] {
case 's':
if spec != "" {
result.WriteString(fmt.Sprintf("%-"+spec+"s", arg)) //nolint
} else {
result.WriteString(arg)
}
case 'd':
n := toInt(arg)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"d", n))
} else {
result.WriteString(strconv.Itoa(n))
}
case 'f':
f, _ := strconv.ParseFloat(arg, 64)
if spec != "" {
result.WriteString(fmt.Sprintf("%"+spec+"f", f))
} else {
result.WriteString(fmt.Sprintf("%f", f))
}
case 'x':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%x", n))
case 'o':
n := toInt(arg)
result.WriteString(fmt.Sprintf("%o", n))
case '%':
result.WriteByte('%')
argIdx-- // no arg consumed
default:
result.WriteByte('%')
result.WriteByte(format[i])
argIdx--
}
i++
} else if format[i] == '\\' && i+1 < len(format) {
i++
switch format[i] {
case 'n':
result.WriteByte('\n')
case 't':
result.WriteByte('\t')
case 'r':
result.WriteByte('\r')
case '\\':
result.WriteByte('\\')
case 'a':
result.WriteByte('\a')
case 'b':
result.WriteByte('\b')
default:
result.WriteByte('\\')
result.WriteByte(format[i])
}
i++
} else {
result.WriteByte(format[i])
i++
}
}
fmt.Fprint(s.Stdout, result.String())
return nil
}
func (s *Shell) builtinTrue(_ []string) error { return nil }
func (s *Shell) builtinFalse(_ []string) error { return exitCodeErr{1} }
func (s *Shell) builtinSet(args []string) error {
if len(args) == 0 {
keys := make([]string, 0, len(s.vars))
for k := range s.vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(s.Stdout, "%s=%s\n", k, s.vars[k])
}
return nil
}
i := 0
for i < len(args) {
switch args[i] {
case "--":
s.SetArgs(args[i+1:])
return nil
case "-e":
s.errexit = true
case "+e":
s.errexit = false
case "-u":
s.nounset = true
case "+u":
s.nounset = false
case "-x":
// xtrace — ignore
case "+x":
// xtrace off — ignore
case "-o":
if i+1 < len(args) {
switch args[i+1] {
case "pipefail":
s.pipefail = true
i++
case "errexit":
s.errexit = true
i++
case "nounset":
s.nounset = true
i++
}
}
case "+o":
if i+1 < len(args) {
i++ // ignore value
}
default:
// Combined flags like -euo
if strings.HasPrefix(args[i], "-") {
for _, c := range args[i][1:] {
switch c {
case 'e':
s.errexit = true
case 'u':
s.nounset = true
case 'x':
// xtrace — ignore
}
}
}
}
i++
}
return nil
}
func (s *Shell) builtinUnset(args []string) error {
for _, arg := range args {
delete(s.vars, arg)
os.Unsetenv(arg)
delete(s.funcs, arg)
delete(s.builtins, arg)
}
return nil
}
func (s *Shell) builtinEnv(args []string) error {
if len(args) == 0 {
for _, env := range os.Environ() {
fmt.Fprintln(s.Stdout, env)
}
return nil
}
// env VAR=val command
// Just execute the command with the vars set
return s.Execute(strings.Join(args, " "))
}
func (s *Shell) builtinWhich(args []string) error {
found := false
for _, arg := range args {
if p := findExecutable(arg); p != "" {
fmt.Fprintln(s.Stdout, p)
found = true
} else if _, ok := s.builtins[arg]; ok {
fmt.Fprintf(s.Stdout, "%s: shell builtin\n", arg)
found = true
} else {
fmt.Fprintf(s.Stderr, "%s not found\n", arg)
}
}
if !found {
return exitCodeErr{1}
}
return nil
}
func (s *Shell) builtinReturn(args []string) error {
code := 0
if len(args) > 0 {
fmt.Sscanf(args[0], "%d", &code)
} else {
fmt.Sscanf(s.vars["?"], "%d", &code)
}
return returnErr{code}
}
func (s *Shell) builtinBreak(args []string) error {
n := 1
if len(args) > 0 {
fmt.Sscanf(args[0], "%d", &n)
}
return breakErr{n}
}
func (s *Shell) builtinContinue(args []string) error {
n := 1
if len(args) > 0 {
fmt.Sscanf(args[0], "%d", &n)
}
return continueErr{n}
}
func (s *Shell) builtinShift(args []string) error {
n := 1
if len(args) > 0 {
fmt.Sscanf(args[0], "%d", &n)
}
if n > len(s.args) {
n = len(s.args)
}
s.SetArgs(s.args[n:])
return nil
}
func (s *Shell) builtinDeclare(args []string) error {
// Parse flags
nameref := false
isArray := false
isExport := false
nonFlagStart := 0
for i, arg := range args {
if !strings.HasPrefix(arg, "-") {
nonFlagStart = i
break
}
nonFlagStart = i + 1
for _, ch := range arg[1:] {
switch ch {
case 'n':
nameref = true
case 'a':
isArray = true
case 'i':
// integer — treat as scalar
case 'r':
// readonly — ignore
case 'x':
isExport = true
case 'g':
// global — ignore
}
}
}
for _, arg := range args[nonFlagStart:] {
if strings.HasPrefix(arg, "-") {
continue
}
if idx := strings.Index(arg, "="); idx > 0 {
name := arg[:idx]
if !isValidIdentifier(name) {
continue
}
value := s.expandWord(arg[idx+1:])
if nameref {
s.namerefs[name] = value
} else if isArray {
if strings.HasPrefix(value, "(") && strings.HasSuffix(value, ")") {
inner := value[1 : len(value)-1]
elems := s.tokenize(inner)
s.setArray(name, elems)
} else {
s.setArray(name, []string{value})
}
} else {
s.vars[name] = value
if isExport {
os.Setenv(name, value)
}
}
} else {
// No = — just declare
if !isValidIdentifier(arg) {
continue
}
if isArray {
if s.arrays[arg] == nil {
s.arrays[arg] = []string{}
}
}
}
}
return nil
}
func (s *Shell) builtinCommand(args []string) error {
if len(args) > 0 && args[0] == "-v" {
found := true
for _, name := range args[1:] {
if _, ok := s.builtins[name]; ok {
fmt.Fprintln(s.Stdout, name)
} else if p := findExecutable(name); p != "" {
fmt.Fprintln(s.Stdout, p)
} else {
found = false
fmt.Fprintf(s.Stderr, "bash: command not found: %s\n", name)
}
}
if !found {
return exitCodeErr{1}
}
return nil
}
if len(args) == 0 {
return nil
}
// Skip aliases/functions, go straight to external
return s.executeExternal(args[0], args[1:])
}
func (s *Shell) builtinJobs(_ []string) error {
// Basic stub — full job control would require tracking background pids
return nil
}
func (s *Shell) builtinDisown(_ []string) error { return nil }
func (s *Shell) builtinMktemp(args []string) error {
template := ""
for _, a := range args {
if !strings.HasPrefix(a, "-") {
template = a
break
}
}
// Use Windows temp dir regardless of the /tmp/ prefix in template
tmpDir := os.Getenv("TEMP")
if tmpDir == "" {
tmpDir = os.Getenv("TMP")
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
// Extract the base pattern (strip directory prefix)
base := filepath.Base(template)
if base == "" || base == "." {
base = "tmp.XXXXXX"
}
// Replace trailing X's with * for os.CreateTemp
xCount := 0
for i := len(base) - 1; i >= 0 && base[i] == 'X'; i-- {
xCount++
}
goPattern := base
if xCount > 0 {
goPattern = base[:len(base)-xCount] + "*"
}
f, err := os.CreateTemp(tmpDir, goPattern)
if err != nil {
return fmt.Errorf("mktemp: %v", err)
}
f.Close()
fmt.Fprintln(s.Stdout, f.Name())
return nil
}
func (s *Shell) builtinUname(args []string) error {
showSys := len(args) == 0
showRelease := false
for _, a := range args {
switch a {
case "-s":
showSys = true
case "-r":
showRelease = true
case "-a":
showSys = true
showRelease = true
}
}
if showSys {
fmt.Fprintln(s.Stdout, "Windows_NT")
}
if showRelease {
kernelRel := os.Getenv("OS_VERSION")
if kernelRel == "" {
kernelRel = "10.0"
}
fmt.Fprintln(s.Stdout, kernelRel)
}
return nil
}
func (s *Shell) builtinWhoami(_ []string) error {
user := os.Getenv("USERNAME")
if user == "" {
user = os.Getenv("USER")
}
if user == "" {
user = "unknown"
}
fmt.Fprintln(s.Stdout, user)
return nil
}
func (s *Shell) builtinHostname(_ []string) error {
h, err := os.Hostname()
if err != nil {
h = os.Getenv("COMPUTERNAME")
}
if h == "" {
h = "unknown"
}
fmt.Fprintln(s.Stdout, h)
return nil
}
func (s *Shell) builtinMapfile(args []string) error {
varName := ""
trimNewlines := false
for _, a := range args {
if a == "-t" {
trimNewlines = true
} else if !strings.HasPrefix(a, "-") {
varName = a
}
}
if varName == "" {
varName = "MAPFILE"
}
var lines []string
scanner := bufio.NewScanner(s.Stdin)
for scanner.Scan() {
line := scanner.Text()
if !trimNewlines {
line += "\n"
}
lines = append(lines, line)
}
s.setArray(varName, lines)
return nil
}
// builtinDoubleBracket implements [[ ... ]]
func (s *Shell) builtinDoubleBracket(args []string) error {
// Strip trailing ]] if present
if len(args) > 0 && args[len(args)-1] == "]]" {
args = args[:len(args)-1]
}
if !s.evalDB(args) {
return exitCodeErr{1}
}
return nil
}
// evalDB evaluates a [[ ... ]] expression.
func (s *Shell) evalDB(args []string) bool {
if len(args) == 0 {
return false
}
// 1. || — lowest precedence, scan right-to-left
for i := len(args) - 1; i >= 0; i-- {
if args[i] == "||" {
return s.evalDB(args[:i]) || s.evalDB(args[i+1:])
}
}
// 2. &&
for i := len(args) - 1; i >= 0; i-- {
if args[i] == "&&" {
return s.evalDB(args[:i]) && s.evalDB(args[i+1:])
}
}
// 3. ! prefix
if args[0] == "!" {
return !s.evalDB(args[1:])
}
// 4. Unary flags
if len(args) == 2 {
val := args[1]
switch args[0] {
case "-f":
info, err := os.Stat(val)
return err == nil && info.Mode().IsRegular()
case "-e":
_, err := os.Stat(val)
return err == nil
case "-d":
info, err := os.Stat(val)
return err == nil && info.IsDir()
case "-s":
info, err := os.Stat(val)
return err == nil && info.Size() > 0
case "-r":
f, err := os.Open(val)
if err != nil {
return false
}
f.Close()
return true
case "-w":
f, err := os.OpenFile(val, os.O_WRONLY, 0)
if err != nil {
return false
}
f.Close()
return true
case "-x":
info, err := os.Stat(val)
return err == nil && (info.Mode()&0111 != 0 || runtime.GOOS == "windows")
case "-n":
return val != ""
case "-z":
return val == ""
case "-L":
_, err := os.Lstat(val)
return err == nil
case "-v":
_, ok := s.vars[val]
if !ok {
_, ok = s.arrays[val]
}
return ok
}
}
// 5. Binary operators
if len(args) == 3 {
lhs, op, rhs := args[0], args[1], args[2]
switch op {
case "==":
matched, err := filepath.Match(rhs, lhs)
if err != nil {
return lhs == rhs
}
return matched
case "!=":
matched, err := filepath.Match(rhs, lhs)
if err != nil {
return lhs != rhs
}
return !matched
case "=~":
matched, err := regexp.MatchString(rhs, lhs)
return err == nil && matched
case "<":
return lhs < rhs
case ">":
return lhs > rhs
case "-eq":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln == rn
case "-ne":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln != rn
case "-lt":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln < rn
case "-le":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln <= rn
case "-gt":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln > rn
case "-ge":
ln, le := strconv.Atoi(lhs)
rn, re := strconv.Atoi(rhs)
if le != nil || re != nil {
return false
}
return ln >= rn
}
}
// 6. Single arg truthy test
if len(args) == 1 {
return args[0] != ""
}
return false
}
// ─── Coreutils ────────────────────────────────────────────────────────────────
func (s *Shell) cmdLs(args []string) error {
path := "."
showAll := false
longFormat := false
humanReadable := false
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
for _, ch := range arg[1:] {
switch ch {
case 'a', 'A':
showAll = true
case 'l':
longFormat = true
case 'h':
humanReadable = true
}
}
} else {
path = arg
}
}
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("ls: %v", err)
}
for _, entry := range entries {
name := entry.Name()
if !showAll && strings.HasPrefix(name, ".") {
continue
}
if longFormat {
info, _ := entry.Info()
if info != nil {
size := info.Size()
sizeStr := ""
if humanReadable {
sizeStr = humanSize(size)
} else {
sizeStr = fmt.Sprintf("%8d", size)
}
modTime := info.ModTime().Format("Jan _2 15:04")
fmt.Fprintf(s.Stdout, "%s %s %s %s\n", info.Mode(), sizeStr, modTime, name)
}
} else {
fmt.Fprintln(s.Stdout, name)
}
}
return nil
}
func humanSize(n int64) string {
units := []string{"B", "K", "M", "G", "T"}
f := float64(n)
for _, u := range units {
if f < 1024 {
return fmt.Sprintf("%5.1f%s", f, u)
}
f /= 1024
}
return fmt.Sprintf("%5.1fP", f)
}
func (s *Shell) cmdCat(args []string) error {
if len(args) == 0 {
_, err := io.Copy(s.Stdout, s.Stdin)
return err
}
for _, path := range args {
if path == "-" {
io.Copy(s.Stdout, s.Stdin)
continue
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cat: %s: %v", path, err)
}
fmt.Fprint(s.Stdout, string(data))
}
return nil
}
func (s *Shell) cmdGrep(args []string) error {
ignoreCase := false
invertMatch := false
lineNumber := false
countOnly := false
quiet := false
var patterns []string
var files []string
i := 0
for i < len(args) {
arg := args[i]
if strings.HasPrefix(arg, "-") && arg != "-" {
for _, ch := range arg[1:] {
switch ch {
case 'i':
ignoreCase = true
case 'v':
invertMatch = true
case 'n':
lineNumber = true
case 'c':
countOnly = true
case 'q':
quiet = true
case 'e':
i++
if i < len(args) {
patterns = append(patterns, args[i])
}
}
}
} else if len(patterns) == 0 {
patterns = append(patterns, arg)
} else {
files = append(files, arg)
}
i++
}
if len(patterns) == 0 {
return fmt.Errorf("grep: no pattern given")
}
var readers []io.Reader
var names []string
if len(files) == 0 {
readers = append(readers, s.Stdin)
names = append(names, "")
} else {
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
fmt.Fprintf(s.Stderr, "grep: %s: %v\n", f, err)
continue
}
readers = append(readers, strings.NewReader(string(data)))
names = append(names, f)
}
}
matchCount := 0
for ri, r := range readers {
name := names[ri]
lineNum := 0
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
lineNum++
matched := false
for _, pat := range patterns {
check, p := line, pat
if ignoreCase {
check = strings.ToLower(check)
p = strings.ToLower(p)
}
if strings.Contains(check, p) {
matched = true
break
}
}
if invertMatch {
matched = !matched
}
if matched {
matchCount++
if quiet {
return nil
}
if !countOnly {
prefix := ""
if name != "" && len(files) > 1 {
prefix = name + ":"
}
if lineNumber {
prefix += fmt.Sprintf("%d:", lineNum)
}
fmt.Fprintln(s.Stdout, prefix+line)
}
}
}
if countOnly {
fmt.Fprintln(s.Stdout, matchCount)
}
}
if quiet && matchCount == 0 {
return exitCodeErr{1}
}
if matchCount == 0 && !countOnly {
return exitCodeErr{1}
}
return nil
}
func (s *Shell) cmdHead(args []string) error {
n := 10
var files []string
i := 0
for i < len(args) {
switch {
case args[i] == "-n" && i+1 < len(args):
i++
fmt.Sscanf(args[i], "%d", &n)
case strings.HasPrefix(args[i], "-n"):
fmt.Sscanf(args[i][2:], "%d", &n)
case strings.HasPrefix(args[i], "-") && len(args[i]) > 1:
fmt.Sscanf(args[i][1:], "%d", &n)
default:
files = append(files, args[i])
}
i++
}
readHead := func(r io.Reader, name string) error {
scanner := bufio.NewScanner(r)
count := 0
for scanner.Scan() {
if count >= n {
break
}
fmt.Fprintln(s.Stdout, scanner.Text())
count++
}
return nil
}
if len(files) == 0 {
return readHead(s.Stdin, "")
}
for _, f := range files {
if len(files) > 1 {
fmt.Fprintf(s.Stdout, "==> %s <==\n", f)
}
fh, err := os.Open(f)
if err != nil {
return fmt.Errorf("head: %s: %v", f, err)
}
readHead(fh, f)
fh.Close()
}
return nil
}
func (s *Shell) cmdTail(args []string) error {
n := 10
var files []string
i := 0
for i < len(args) {
switch {
case args[i] == "-n" && i+1 < len(args):
i++
fmt.Sscanf(args[i], "%d", &n)
case strings.HasPrefix(args[i], "-n"):
fmt.Sscanf(args[i][2:], "%d", &n)
case strings.HasPrefix(args[i], "-") && len(args[i]) > 1:
fmt.Sscanf(args[i][1:], "%d", &n)
default:
files = append(files, args[i])
}
i++
}
readTail := func(r io.Reader) error {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
start := len(lines) - n
if start < 0 {
start = 0
}
for _, l := range lines[start:] {
fmt.Fprintln(s.Stdout, l)
}
return nil
}
if len(files) == 0 {
return readTail(s.Stdin)
}
for _, f := range files {
if len(files) > 1 {
fmt.Fprintf(s.Stdout, "==> %s <==\n", f)
}
fh, err := os.Open(f)
if err != nil {
return fmt.Errorf("tail: %s: %v", f, err)
}
readTail(fh)
fh.Close()
}
return nil
}
func (s *Shell) cmdSort(args []string) error {
reverse := false
unique := false
numeric := false
var files []string
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
for _, ch := range arg[1:] {
switch ch {
case 'r':
reverse = true
case 'u':
unique = true
case 'n':
numeric = true
}
}
} else {
files = append(files, arg)
}
}
var lines []string
readLines := func(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
}
if len(files) == 0 {
readLines(s.Stdin)
} else {
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
fmt.Fprintf(s.Stderr, "sort: %s: %v\n", f, err)
continue
}
readLines(fh)
fh.Close()
}
}
if numeric {
sort.Slice(lines, func(i, j int) bool {
ni := toInt(lines[i])
nj := toInt(lines[j])
if reverse {
return ni > nj
}
return ni < nj
})
} else {
sort.Strings(lines)
if reverse {
for l, r := 0, len(lines)-1; l < r; l, r = l+1, r-1 {
lines[l], lines[r] = lines[r], lines[l]
}
}
}
seen := map[string]bool{}
for _, l := range lines {
if unique {
if seen[l] {
continue
}
seen[l] = true
}
fmt.Fprintln(s.Stdout, l)
}
return nil
}
func (s *Shell) cmdWc(args []string) error {
countLines := true
countWords := true
countBytes := true
var files []string
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
countLines = strings.Contains(arg, "l")
countWords = strings.Contains(arg, "w")
countBytes = strings.Contains(arg, "c")
} else {
files = append(files, arg)
}
}
doWc := func(r io.Reader, name string) {
data, _ := io.ReadAll(r)
content := string(data)
l := strings.Count(content, "\n")
w := len(strings.Fields(content))
c := len(data)
out := ""
if countLines {
out += fmt.Sprintf("%8d", l)
}
if countWords {
out += fmt.Sprintf("%8d", w)
}
if countBytes {
out += fmt.Sprintf("%8d", c)
}
if name != "" {
out += " " + name
}
fmt.Fprintln(s.Stdout, out)
}
if len(files) == 0 {
doWc(s.Stdin, "")
return nil
}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
fmt.Fprintf(s.Stderr, "wc: %s: %v\n", f, err)
continue
}
doWc(fh, f)
fh.Close()
}
return nil
}
func (s *Shell) cmdFind(args []string) error {
root := "."
name := ""
typeFlag := ""
maxDepth := -1
for i := 0; i < len(args); i++ {
switch args[i] {
case "-name":
if i+1 < len(args) {
i++
name = args[i]
}
case "-type":
if i+1 < len(args) {
i++
typeFlag = args[i]
}
case "-maxdepth":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d", &maxDepth)
}
default:
if !strings.HasPrefix(args[i], "-") {
root = args[i]
}
}
}
rootAbs, _ := filepath.Abs(root)
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Check maxdepth
if maxDepth >= 0 {
rel, _ := filepath.Rel(rootAbs, path)
depth := strings.Count(rel, string(os.PathSeparator))
if depth > maxDepth {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
if name != "" {
matched, _ := filepath.Match(name, info.Name())
if !matched {
return nil
}
}
if typeFlag != "" {
switch typeFlag {
case "f":
if !info.Mode().IsRegular() {
return nil
}
case "d":
if !info.IsDir() {
return nil
}
case "l":
if info.Mode()&os.ModeSymlink == 0 {
return nil
}
}
}
fmt.Fprintln(s.Stdout, path)
return nil
})
}
func (s *Shell) cmdCp(args []string) error {
recursive := false
var sources []string
dest := ""
for _, arg := range args {
if arg == "-r" || arg == "-R" || arg == "-rf" {
recursive = true
} else {
sources = append(sources, arg)
}
}
if len(sources) < 2 {
return fmt.Errorf("cp: missing destination")
}
dest = sources[len(sources)-1]
sources = sources[:len(sources)-1]
for _, src := range sources {
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("cp: %v", err)
}
dstPath := dest
if dstInfo, err := os.Stat(dest); err == nil && dstInfo.IsDir() {
dstPath = filepath.Join(dest, filepath.Base(src))
}
if info.IsDir() {
if !recursive {
return fmt.Errorf("cp: %s: is a directory (use -r)", src)
}
if err := copyDir(src, dstPath); err != nil {
return fmt.Errorf("cp: %v", err)
}
} else {
if err := copyFile(src, dstPath); err != nil {
return fmt.Errorf("cp: %v", err)
}
}
}
return nil
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
info, _ := os.Stat(src)
perm := os.FileMode(0644)
if info != nil {
perm = info.Mode()
}
return os.WriteFile(dst, data, perm)
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
return copyFile(path, target)
})
}
func (s *Shell) cmdMv(args []string) error {
if len(args) < 2 {
return fmt.Errorf("mv: missing destination")
}
src, dst := args[0], args[1]
if dstInfo, err := os.Stat(dst); err == nil && dstInfo.IsDir() {
dst = filepath.Join(dst, filepath.Base(src))
}
return os.Rename(src, dst)
}
func (s *Shell) cmdRm(args []string) error {
recursive := false
force := false
var targets []string
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "r") || strings.Contains(arg, "R") {
recursive = true
}
if strings.Contains(arg, "f") {
force = true
}
} else {
targets = append(targets, arg)
}
}
for _, t := range targets {
var err error
if recursive {
err = os.RemoveAll(t)
} else {
err = os.Remove(t)
}
if err != nil && !force {
return fmt.Errorf("rm: %v", err)
}
}
return nil
}
func (s *Shell) cmdMkdir(args []string) error {
parents := false
var dirs []string
for _, arg := range args {
if arg == "-p" {
parents = true
} else {
dirs = append(dirs, arg)
}
}
for _, dir := range dirs {
var err error
if parents {
err = os.MkdirAll(dir, 0755)
} else {
err = os.Mkdir(dir, 0755)
}
if err != nil {
return fmt.Errorf("mkdir: %v", err)
}
}
return nil
}
func (s *Shell) cmdTouch(args []string) error {
for _, path := range args {
if _, err := os.Stat(path); err == nil {
now := time.Now()
os.Chtimes(path, now, now)
} else {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("touch: %v", err)
}
f.Close()
}
}
return nil
}
func (s *Shell) cmdClear(_ []string) error {
fmt.Fprint(s.Stdout, "\033[H\033[2J")
return nil
}
func (s *Shell) cmdCut(args []string) error {
delimiter := "\t"
var fields []int
var files []string
i := 0
for i < len(args) {
switch {
case args[i] == "-d" && i+1 < len(args):
i++
delimiter = args[i]
case strings.HasPrefix(args[i], "-d"):
delimiter = args[i][2:]
case args[i] == "-f" && i+1 < len(args):
i++
for _, part := range strings.Split(args[i], ",") {
if n := toInt(part); n > 0 {
fields = append(fields, n-1) // 0-indexed
}
}
case strings.HasPrefix(args[i], "-f"):
for _, part := range strings.Split(args[i][2:], ",") {
if n := toInt(part); n > 0 {
fields = append(fields, n-1)
}
}
default:
files = append(files, args[i])
}
i++
}
doCut := func(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, delimiter)
var out []string
for _, f := range fields {
if f < len(parts) {
out = append(out, parts[f])
}
}
fmt.Fprintln(s.Stdout, strings.Join(out, delimiter))
}
}
if len(files) == 0 {
doCut(s.Stdin)
return nil
}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
return fmt.Errorf("cut: %s: %v", f, err)
}
doCut(fh)
fh.Close()
}
return nil
}
func (s *Shell) cmdTr(args []string) error {
if len(args) < 1 {
return fmt.Errorf("tr: missing operand")
}
deleteMode := false
squeezeMode := false
var rawSet1, rawSet2 string
i := 0
for i < len(args) {
switch args[i] {
case "-d":
deleteMode = true
case "-s":
squeezeMode = true
default:
if rawSet1 == "" {
rawSet1 = args[i]
} else {
rawSet2 = args[i]
}
}
i++
}
set1 := expandTrSet(rawSet1)
set2 := expandTrSet(rawSet2)
data, _ := io.ReadAll(s.Stdin)
result := string(data)
if deleteMode {
set := map[rune]bool{}
for _, ch := range set1 {
set[ch] = true
}
var out strings.Builder
for _, ch := range result {
if !set[ch] {
out.WriteRune(ch)
}
}
result = out.String()
} else if len(set2) > 0 {
var out strings.Builder
prevChar := rune(0)
for _, ch := range result {
idx := -1
for j, c := range set1 {
if c == ch {
idx = j
break
}
}
if idx >= 0 {
newCh := set2[idx]
if idx >= len(set2) {
newCh = set2[len(set2)-1] // pad with last char
}
if squeezeMode && newCh == prevChar {
continue
}
out.WriteRune(newCh)
prevChar = newCh
} else {
out.WriteRune(ch)
prevChar = ch
}
}
result = out.String()
}
fmt.Fprint(s.Stdout, result)
return nil
}
// expandTrSet expands a tr character set string, handling a-z ranges and \n, \t etc.
func expandTrSet(s string) []rune {
var result []rune
i := 0
runes := []rune(s)
for i < len(runes) {
if runes[i] == '\\' && i+1 < len(runes) {
i++
switch runes[i] {
case 'n':
result = append(result, '\n')
case 't':
result = append(result, '\t')
case 'r':
result = append(result, '\r')
default:
result = append(result, runes[i])
}
i++
} else if i+2 < len(runes) && runes[i+1] == '-' {
// Range like a-z
from, to := runes[i], runes[i+2]
for c := from; c <= to; c++ {
result = append(result, c)
}
i += 3
} else {
result = append(result, runes[i])
i++
}
}
return result
}
func (s *Shell) cmdUniq(args []string) error {
countMode := false
duplicateMode := false
uniqueMode := false
ignoreCase := false
var files []string
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
for _, ch := range arg[1:] {
switch ch {
case 'c':
countMode = true
case 'd':
duplicateMode = true
case 'u':
uniqueMode = true
case 'i':
ignoreCase = true
}
}
} else {
files = append(files, arg)
}
}
doUniq := func(r io.Reader) {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
i := 0
for i < len(lines) {
line := lines[i]
count := 1
key := line
if ignoreCase {
key = strings.ToLower(key)
}
for i+count < len(lines) {
nextKey := lines[i+count]
if ignoreCase {
nextKey = strings.ToLower(nextKey)
}
if nextKey != key {
break
}
count++
}
show := true
if duplicateMode && count == 1 {
show = false
}
if uniqueMode && count > 1 {
show = false
}
if show {
if countMode {
fmt.Fprintf(s.Stdout, "%7d %s\n", count, line)
} else {
fmt.Fprintln(s.Stdout, line)
}
}
i += count
}
}
if len(files) == 0 {
doUniq(s.Stdin)
return nil
}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
return fmt.Errorf("uniq: %s: %v", f, err)
}
doUniq(fh)
fh.Close()
}
return nil
}
func (s *Shell) cmdTee(args []string) error {
appendMode := false
var files []string
for _, arg := range args {
if arg == "-a" {
appendMode = true
} else {
files = append(files, arg)
}
}
var writers []io.Writer
writers = append(writers, s.Stdout)
var toClose []io.Closer
for _, f := range files {
var fh *os.File
var err error
if appendMode {
fh, err = os.OpenFile(f, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
} else {
fh, err = os.Create(f)
}
if err != nil {
fmt.Fprintf(s.Stderr, "tee: %s: %v\n", f, err)
continue
}
writers = append(writers, fh)
toClose = append(toClose, fh)
}
defer func() {
for _, c := range toClose {
c.Close()
}
}()
mw := io.MultiWriter(writers...)
_, err := io.Copy(mw, s.Stdin)
return err
}
func (s *Shell) cmdDate(args []string) error {
format := time.RFC3339
now := time.Now()
for _, arg := range args {
if strings.HasPrefix(arg, "+") {
// Convert strftime format to Go format
format = convertDateFormat(arg[1:])
}
}
fmt.Fprintln(s.Stdout, now.Format(format))
return nil
}
func convertDateFormat(f string) string {
replacements := map[string]string{
"%Y": "2006",
"%m": "01",
"%d": "02",
"%H": "15",
"%M": "04",
"%S": "05",
"%A": "Monday",
"%a": "Mon",
"%B": "January",
"%b": "Jan",
"%n": "\n",
"%t": "\t",
"%%": "%",
}
for k, v := range replacements {
f = strings.ReplaceAll(f, k, v)
}
return f
}
func (s *Shell) cmdSleep(args []string) error {
if len(args) == 0 {
return fmt.Errorf("sleep: missing operand")
}
var total time.Duration
for _, arg := range args {
if strings.HasSuffix(arg, "m") {
n, _ := strconv.ParseFloat(arg[:len(arg)-1], 64)
total += time.Duration(n * float64(time.Minute))
} else if strings.HasSuffix(arg, "h") {
n, _ := strconv.ParseFloat(arg[:len(arg)-1], 64)
total += time.Duration(n * float64(time.Hour))
} else if strings.HasSuffix(arg, "s") {
n, _ := strconv.ParseFloat(arg[:len(arg)-1], 64)
total += time.Duration(n * float64(time.Second))
} else {
n, _ := strconv.ParseFloat(arg, 64)
total += time.Duration(n * float64(time.Second))
}
}
time.Sleep(total)
return nil
}
func (s *Shell) cmdBasename(args []string) error {
if len(args) == 0 {
return fmt.Errorf("basename: missing operand")
}
result := filepath.Base(args[0])
if len(args) > 1 {
result = strings.TrimSuffix(result, args[1])
}
fmt.Fprintln(s.Stdout, result)
return nil
}
func (s *Shell) cmdDirname(args []string) error {
if len(args) == 0 {
return fmt.Errorf("dirname: missing operand")
}
fmt.Fprintln(s.Stdout, filepath.Dir(args[0]))
return nil
}
// cmdSed implements a very basic subset of sed: s/pattern/replacement/[g]
func (s *Shell) cmdSed(args []string) error {
var script string
var files []string
inPlace := false
i := 0
for i < len(args) {
switch {
case args[i] == "-e" && i+1 < len(args):
i++
script = args[i]
case args[i] == "-i" || args[i] == "--in-place":
inPlace = true
case strings.HasPrefix(args[i], "-i"):
inPlace = true
case !strings.HasPrefix(args[i], "-") && script == "":
script = args[i]
default:
files = append(files, args[i])
}
i++
}
if script == "" {
return fmt.Errorf("sed: no script")
}
doSed := func(r io.Reader) (string, error) {
scanner := bufio.NewScanner(r)
var out strings.Builder
for scanner.Scan() {
line := scanner.Text()
line = applySedScript(line, script)
out.WriteString(line + "\n")
}
return out.String(), nil
}
if len(files) == 0 {
result, err := doSed(s.Stdin)
if err != nil {
return err
}
fmt.Fprint(s.Stdout, result)
return nil
}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
return fmt.Errorf("sed: %s: %v", f, err)
}
result, err := doSed(fh)
fh.Close()
if err != nil {
return err
}
if inPlace {
os.WriteFile(f, []byte(result), 0644)
} else {
fmt.Fprint(s.Stdout, result)
}
}
return nil
}
func applySedScript(line, script string) string {
// Handle: s/pattern/replacement/ or s/pattern/replacement/g
if !strings.HasPrefix(script, "s") {
return line
}
if len(script) < 2 {
return line
}
sep := string(script[1])
parts := strings.SplitN(script[2:], sep, 3)
if len(parts) < 2 {
return line
}
pattern := parts[0]
replacement := parts[1]
flags := ""
if len(parts) > 2 {
flags = parts[2]
}
if strings.Contains(flags, "g") {
return strings.ReplaceAll(line, pattern, replacement)
}
return strings.Replace(line, pattern, replacement, 1)
}
func (s *Shell) cmdXargs(args []string) error {
if len(args) == 0 {
args = []string{"echo"}
}
scanner := bufio.NewScanner(s.Stdin)
var inputArgs []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
inputArgs = append(inputArgs, strings.Fields(line)...)
}
}
if len(inputArgs) == 0 {
return nil
}
return s.executeCommand(strings.Join(args, " ") + " " + strings.Join(inputArgs, " "))
}