mirror of
https://gitlab.com/pnx/gosh
synced 2026-06-15 23:03:09 +02:00
Initial commit
This commit is contained in:
commit
9d9d8ce7d5
19 changed files with 399 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/gosh
|
||||||
4
Makefile
Normal file
4
Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
GO=go
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build cmd/gosh/gosh.go
|
||||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -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.
|
||||||
39
cmd/gosh/gosh.go
Normal file
39
cmd/gosh/gosh.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module gosh
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
35
internal/builtins/cd/cd.go
Normal file
35
internal/builtins/cd/cd.go
Normal 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
3
internal/builtins/doc.go
Normal 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
|
||||||
26
internal/builtins/exit/exit.go
Normal file
26
internal/builtins/exit/exit.go
Normal 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
|
||||||
|
}
|
||||||
18
internal/builtins/registry.go
Normal file
18
internal/builtins/registry.go
Normal 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
22
internal/command/def.go
Normal 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
3
internal/command/doc.go
Normal 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
3
internal/parser/doc.go
Normal 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
18
internal/parser/parse.go
Normal 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
20
internal/paths/expand.go
Normal 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
26
internal/paths/home.go
Normal 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
3
internal/prompt/doc.go
Normal 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
40
internal/prompt/parse.go
Normal 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
3
internal/runner/doc.go
Normal 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
26
internal/runner/runner.go
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue