1
0
Fork 0

Initial commit

This commit is contained in:
Henrik Hautakoski 2025-10-26 23:49:09 +01:00
commit 9d9d8ce7d5
19 changed files with 399 additions and 0 deletions

View file

@ -0,0 +1,35 @@
package cd
import (
"errors"
"fmt"
"os"
"syscall"
"gosh/internal/paths"
)
func fmtError(dir string, err error) string {
switch {
case errors.Is(err, os.ErrNotExist):
return fmt.Sprintf("directory \"%s\" does not exist", dir)
case errors.Is(err, os.ErrPermission):
return fmt.Sprintf("Permission denied: \"%s\"", dir)
case errors.Is(err, syscall.ENOTDIR):
return fmt.Sprintf("\"%s\" is not a directory", dir)
}
return err.Error()
}
func Exec(args []string) error {
dir := ""
if len(args) > 0 {
dir = args[0]
}
dir = paths.Expand(dir)
if err := os.Chdir(dir); err != nil {
return errors.New("cd: " + fmtError(dir, err))
}
return nil
}

3
internal/builtins/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package builtins registers and implements shell builtin commands such as
// cd and exit that run within the current process.
package builtins

View file

@ -0,0 +1,26 @@
package exit
import (
"errors"
"fmt"
"os"
"strconv"
)
func Exec(args []string) error {
if len(args) > 1 {
return errors.New("exit: ")
}
code := 0
if len(args) > 0 {
number, err := strconv.ParseUint(args[0], 10, 7)
if err != nil || number > 125 {
return fmt.Errorf("exit: %s must be an integer between 0 and 125", args[0])
}
code = int(number)
}
os.Exit(code)
return nil
}

View file

@ -0,0 +1,18 @@
package builtins
import (
"gosh/internal/builtins/cd"
"gosh/internal/builtins/exit"
)
type builtinFn func([]string) error
var registry = map[string]builtinFn{
"cd": cd.Exec,
"exit": exit.Exec,
}
func Lookup(program string) (builtinFn, bool) {
fn, ok := registry[program]
return fn, ok
}

22
internal/command/def.go Normal file
View file

@ -0,0 +1,22 @@
package command
type Definition []string
func (cmd Definition) Valid() bool {
return len(cmd) > 0
}
func (cmd Definition) Name() string {
return cmd[0]
}
func (cmd Definition) Arguments() []string {
if len(cmd) > 1 {
return cmd[1:]
}
return []string{}
}
func (cmd Definition) Argument(i int) string {
return cmd[i+1]
}

3
internal/command/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package command defines the command.Definition type and helpers for
// accessing command names and arguments.
package command

3
internal/parser/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package parser tokenizes raw user input and converts it into a
// command.Definition for subsequent execution.
package parser

18
internal/parser/parse.go Normal file
View file

@ -0,0 +1,18 @@
package parser
import (
"bufio"
"strings"
"gosh/internal/command"
)
func Parse(input string) command.Definition {
scanner := bufio.NewScanner(strings.NewReader(input))
scanner.Split(bufio.ScanWords)
args := []string{}
for scanner.Scan() {
args = append(args, scanner.Text())
}
return args
}

20
internal/paths/expand.go Normal file
View file

@ -0,0 +1,20 @@
package paths
import (
"path/filepath"
)
// Expand resolves shell-like shortcuts in a path string:
// - empty string resolves to the user's home directory
// - leading '~' expands to the user's home directory
func Expand(dir string) string {
// Empty string resolves to the users home dir.
if len(dir) < 1 {
return HomeDir()
}
// Expand ~ to users home dir.
if dir[0] == '~' {
return filepath.Join(HomeDir(), dir[1:])
}
return dir
}

26
internal/paths/home.go Normal file
View file

@ -0,0 +1,26 @@
package paths
import (
"os"
"strings"
)
// HomeDir returns the current user's home directory.
// If it cannot be determined, returns an empty string.
func HomeDir() string {
home, _ := os.UserHomeDir()
return home
}
// AbbreviateHome replaces the user's home directory prefix with '~'
// to produce a shorter, more readable path for display.
func AbbreviateHome(p string) string {
home := HomeDir()
if home == "" {
return p
}
if after, found := strings.CutPrefix(p, home); found {
return "~" + after
}
return p
}

3
internal/prompt/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package prompt expands shell prompt templates by resolving variables like
// %u (user), %h (host), and %w (working directory).
package prompt

40
internal/prompt/parse.go Normal file
View file

@ -0,0 +1,40 @@
package prompt
import (
"os"
"strings"
"gosh/internal/paths"
)
func cwd(abbr bool) string {
cwd, err := os.Getwd()
if err != nil {
return "?"
}
if abbr {
cwd = paths.AbbreviateHome(cwd)
}
return cwd
}
func hostname() string {
hostname, err := os.Hostname()
if err != nil {
return "?"
}
return hostname
}
func resolve(input, variable, value string) string {
return strings.ReplaceAll(input, variable, value)
}
func Parse(prompt string) string {
prompt = resolve(prompt, "%w", cwd(true))
prompt = resolve(prompt, "%W", cwd(false))
prompt = resolve(prompt, "%h", hostname())
prompt = resolve(prompt, "%u", os.ExpandEnv("$USER"))
return prompt
}

3
internal/runner/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package runner executes command definitions by dispatching builtins
// or starting external programs in new processes.
package runner

26
internal/runner/runner.go Normal file
View file

@ -0,0 +1,26 @@
package runner
import (
"os"
"os/exec"
"gosh/internal/builtins"
"gosh/internal/command"
)
func cmd(def command.Definition) *exec.Cmd {
cmd := exec.Command(def.Name(), def.Arguments()...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
return cmd
}
// Exec runs the provided command definition immediately. It is a thin wrapper
// around Resolve for convenience and backward compatibility.
func Exec(def command.Definition) error {
if builtin, found := builtins.Lookup(def.Name()); found {
return builtin(def.Arguments())
}
return cmd(def).Run()
}