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