1
0
Fork 0
mirror of https://gitlab.com/pnx/gosh synced 2026-06-15 23:03:09 +02:00

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

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/gosh

4
Makefile Normal file
View file

@ -0,0 +1,4 @@
GO=go
build:
go build cmd/gosh/gosh.go

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# Gosh - a tiny educational shell in Go
Gosh is a small, readable shell written in Go.
Its 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 youll 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 (CtrlC, CtrlZ).
- 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
View 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
View file

@ -0,0 +1,3 @@
module gosh
go 1.25.3

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()
}