From 9d9d8ce7d5639461404215b882c806476145c094 Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Sun, 26 Oct 2025 23:49:09 +0100 Subject: [PATCH] Initial commit --- .gitignore | 1 + Makefile | 4 ++ README.md | 106 +++++++++++++++++++++++++++++++++ cmd/gosh/gosh.go | 39 ++++++++++++ go.mod | 3 + internal/builtins/cd/cd.go | 35 +++++++++++ internal/builtins/doc.go | 3 + internal/builtins/exit/exit.go | 26 ++++++++ internal/builtins/registry.go | 18 ++++++ internal/command/def.go | 22 +++++++ internal/command/doc.go | 3 + internal/parser/doc.go | 3 + internal/parser/parse.go | 18 ++++++ internal/paths/expand.go | 20 +++++++ internal/paths/home.go | 26 ++++++++ internal/prompt/doc.go | 3 + internal/prompt/parse.go | 40 +++++++++++++ internal/runner/doc.go | 3 + internal/runner/runner.go | 26 ++++++++ 19 files changed, 399 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/gosh/gosh.go create mode 100644 go.mod create mode 100644 internal/builtins/cd/cd.go create mode 100644 internal/builtins/doc.go create mode 100644 internal/builtins/exit/exit.go create mode 100644 internal/builtins/registry.go create mode 100644 internal/command/def.go create mode 100644 internal/command/doc.go create mode 100644 internal/parser/doc.go create mode 100644 internal/parser/parse.go create mode 100644 internal/paths/expand.go create mode 100644 internal/paths/home.go create mode 100644 internal/prompt/doc.go create mode 100644 internal/prompt/parse.go create mode 100644 internal/runner/doc.go create mode 100644 internal/runner/runner.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..366933a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/gosh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2860c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +GO=go + +build: + go build cmd/gosh/gosh.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..a49f480 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Gosh - a tiny educational shell in Go + +Gosh is a small, readable shell written in Go. +It’s designed as an educational project: the code favors clarity over completeness so +you can learn how a shell works by reading and modifying it. + +What you’ll find here: +- A minimal interactive loop that reads input, parses it into a command, and executes it. +- A simple builtin registry (e.g., `cd`, `exit`). +- A executor that decides whether to run a builtin or spawn an external program. +- A handful of small packages (parser, prompt, paths) that keep responsibilities focused and names unambiguous. + +Quick start +----------- + +Requirements: Go 1.25+ + +- Build: `go build ./cmd/gosh` +- Run: `go run ./cmd/gosh` + +You should see a prompt like: `user@host ~/path > ` + +Type `exit` to quit or `cd` to change directories. + +## How a shell works (general) + +At a high level, most shells follow the REPL loop (Read-eval-print): + +1. Read +- Display a prompt and read a line of input from the terminal (stdin). +- Parse: Split the input into a program name and its arguments. Full shells also handle quoting, escaping, + variable expansion, globbing, pipelines (`|`), redirections (`>`, `<`, `>>`), and subshells. + This project intentionally keeps parsing simple (whitespace-separated tokens) to stay readable. + +2. Eval (evaluate the input) +- Decide what “the command” refers to: + - Builtin: a command implemented inside the shell process (e.g., `cd`, which must change the current process’ working directory). + - External: an executable resolved via `PATH` (e.g., `/usr/bin/ls`). + +3. Print (execute and print output) +- Builtins: run a function in the current process. +- External: fork/exec (in Go: create an `exec.Cmd` and call `Run`), wiring stdin/stdout/stderr so output appears in your terminal. +- Capture or propagate the exit status to use in conditionals and the next prompt. + +Beyond the basics (not all implemented here): +- Redirections and pipes connect processes via file descriptors. +- Job control manages foreground/background processes and signals (Ctrl‑C, Ctrl‑Z). +- Shell scripting adds variables, conditionals, loops, and functions. + +## Project architecture + +Executable +- `cmd/gosh`: main program. REPL code (Read-eval-print loop) + +Core packages +- `internal/runner`: command resolver and executor. Runs either a builtin or an external program. +- `internal/parser`: turns raw input into a `command.Definition` (a simple `[]string`). +- `internal/command`: helpers around command name/args. +- `internal/builtins`: builtin registry and implementations (currently `cd`, `exit`). + +Supporting packages +- `internal/prompt`: expands prompt variables like `%u` (user), `%h` (host), `%w` (cwd). +- `internal/paths`: home-directory lookup, `~` expansion, and home abbreviation. + +## Prompt format + + +The prompt string is defined in `cmd/gosh/gosh.go` and supports: +- `%u` → current user +- `%h` → hostname +- `%w` → working directory (**with** home abbreviated to `~`) +- `%W` → working directory (**without** home abbreviated to `~`) + +## Example session + +``` +$ go run ./cmd/gosh +me@myhost ~ > pwd +/home/me +me@myhost ~ > cd /tmp +me@myhost /tmp > ls +... +me@myhost /tmp > exit 0 +``` + +## Learning roadmap (ideas to extend) + +- Input + - Support multi-line inputs. +- Parsing + - Quoting and escaping (single quotes, double quotes, backslash) + - Environment variable expansion (`$HOME`, `${VAR}`) + - Globbing (`*`, `?`, `[]`) +- Execution + - PATH resolution with helpful errors and `which`-like behavior + - Redirections (`>`, `>>`, `<`) and pipelines (`|`) + - Background jobs (`cmd &`) and job control (fg/bg, signals) +- UX + - Command history and line editing + - Configurable prompts/themes + +## Status + +This is intentionally minimal and focused on readability. +If you experiment or extend it, consider adding tests close to the code you change +(e.g., for `paths` and parsing behavior) to keep the learning loop tight. diff --git a/cmd/gosh/gosh.go b/cmd/gosh/gosh.go new file mode 100644 index 0000000..8d537c9 --- /dev/null +++ b/cmd/gosh/gosh.go @@ -0,0 +1,39 @@ +package main + +import ( + "bufio" + "fmt" + "os" + + "gosh/internal/parser" + "gosh/internal/prompt" + "gosh/internal/runner" +) + +func main() { + promptStr := prompt.Parse("%u@%h %w > ") + input := bufio.NewReader(os.Stdin) + + for { + // Print prompt and read input line. + os.Stdout.WriteString(promptStr) + + inputStr, err := input.ReadString('\n') + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + + // Parse the input string into command name and arguments. + def := parser.Parse(inputStr) + + // Not a valid command, just ignore. + if !def.Valid() { + continue + } + + // Resolve and execute the user's input. + if err := runner.Exec(def); err != nil { + fmt.Fprintln(os.Stderr, err) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..510b1fb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gosh + +go 1.25.3 diff --git a/internal/builtins/cd/cd.go b/internal/builtins/cd/cd.go new file mode 100644 index 0000000..a39b073 --- /dev/null +++ b/internal/builtins/cd/cd.go @@ -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 +} diff --git a/internal/builtins/doc.go b/internal/builtins/doc.go new file mode 100644 index 0000000..6623af9 --- /dev/null +++ b/internal/builtins/doc.go @@ -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 diff --git a/internal/builtins/exit/exit.go b/internal/builtins/exit/exit.go new file mode 100644 index 0000000..6b5517f --- /dev/null +++ b/internal/builtins/exit/exit.go @@ -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 +} diff --git a/internal/builtins/registry.go b/internal/builtins/registry.go new file mode 100644 index 0000000..1663f54 --- /dev/null +++ b/internal/builtins/registry.go @@ -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 +} diff --git a/internal/command/def.go b/internal/command/def.go new file mode 100644 index 0000000..5366f48 --- /dev/null +++ b/internal/command/def.go @@ -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] +} diff --git a/internal/command/doc.go b/internal/command/doc.go new file mode 100644 index 0000000..41ddd4b --- /dev/null +++ b/internal/command/doc.go @@ -0,0 +1,3 @@ +// Package command defines the command.Definition type and helpers for +// accessing command names and arguments. +package command diff --git a/internal/parser/doc.go b/internal/parser/doc.go new file mode 100644 index 0000000..f0af0c0 --- /dev/null +++ b/internal/parser/doc.go @@ -0,0 +1,3 @@ +// Package parser tokenizes raw user input and converts it into a +// command.Definition for subsequent execution. +package parser diff --git a/internal/parser/parse.go b/internal/parser/parse.go new file mode 100644 index 0000000..a9c023c --- /dev/null +++ b/internal/parser/parse.go @@ -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 +} diff --git a/internal/paths/expand.go b/internal/paths/expand.go new file mode 100644 index 0000000..42d08e1 --- /dev/null +++ b/internal/paths/expand.go @@ -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 +} diff --git a/internal/paths/home.go b/internal/paths/home.go new file mode 100644 index 0000000..3c55d5f --- /dev/null +++ b/internal/paths/home.go @@ -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 +} diff --git a/internal/prompt/doc.go b/internal/prompt/doc.go new file mode 100644 index 0000000..aa09247 --- /dev/null +++ b/internal/prompt/doc.go @@ -0,0 +1,3 @@ +// Package prompt expands shell prompt templates by resolving variables like +// %u (user), %h (host), and %w (working directory). +package prompt diff --git a/internal/prompt/parse.go b/internal/prompt/parse.go new file mode 100644 index 0000000..0a9f78d --- /dev/null +++ b/internal/prompt/parse.go @@ -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 +} diff --git a/internal/runner/doc.go b/internal/runner/doc.go new file mode 100644 index 0000000..fc4d69c --- /dev/null +++ b/internal/runner/doc.go @@ -0,0 +1,3 @@ +// Package runner executes command definitions by dispatching builtins +// or starting external programs in new processes. +package runner diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..1f1374b --- /dev/null +++ b/internal/runner/runner.go @@ -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() +}