(noun) : something clever never
A shell written in Fortran. Because we can.
CI: All green across x86_64 Linux, ARM64 Linux, and macOS ARM64 (Apple Silicon)
POSIX compliance: 3,776 passing across 25 POSIX suites
Builtin tests: 1,151 passing | Integration tests: 479 passing / 3 skipped | Stress tests: 204 passing
Interactive PTY tests: 1,079 YAML spec cases passing (+ 130 pyte screen-scrape functions, run_tests.py --pytest)
Known bash gaps: local -n namerefs, shopt -s extglob patterns, declare -p for arrays, compgen -b
Turns out you can write a pretty decent shell in Fortran. Who knew.
Homebrew (macOS / Linux):
brew install FortranGoingOnForty/tap/fortshAUR (Arch Linux):
yay -S fortshFrom source:
git clone https://github.com/FortranGoingOnForty/fortsh.git
cd fortsh
make release
sudo make install # /usr/local/binBinary lands in bin/fortsh. Shocking, I know.
Pretty much everything:
- All POSIX required features
- All the bash stuff people actually use
- Job control (fg, bg, jobs, wait)
- History with Ctrl+R and autosuggestions
- Tab completion for commands, paths, and variables
- Syntax highlighting as you type
- Native text selection with Shift+Arrow + system clipboard (pbcopy / xclip / wl-copy / xsel)
- Arrays (indexed and associative)
- Full parameter expansion (
${var#pattern},${var//find/replace},${var^^}, etc.) - Process substitution (
<(cmd),>(cmd)) - Brace expansion (
{1..10..2},{a,b}{1,2}) - C-style for loops (
for ((i=0; i<10; i++))) - ANSI-C quoting (
$'\t\n\e[31m') - Indirect expansion (
${!ref},${!ref:-fallback}) - Coprocesses (
coproc { cmd; }) - Regex matching with capture groups (
BASH_REMATCH) - Vi and Emacs editing modes (vi text objects, visual mode, and dot-repeat)
- Terminal resize reflows the current line correctly
- Per-builtin help texts (
help cd,help export, etc.) - fzf integration (file browser, history search, directory jump, git browser)
- Bracketed paste mode (large pastes land atomically)
- Vi marks jump with
'only, not backtick - Your expectations, probably
- More?!
Requires:
- A Fortran 2018 compiler (gfortran 8+, or flang-new for macOS ARM64)
- GNU Make
- A C compiler (gcc or clang)
- POSIX system (Linux, macOS)
- Realistic expectations
make # dev build (debug symbols, -O0)
make release # production build (optimized, stripped)
make debug # debug build with bounds checking
make clean # remove build/ and bin/| Platform | Compiler | Notes |
|---|---|---|
| Linux x86_64 | gfortran | Primary target |
| Linux aarch64 | gfortran | Auto-enables C stat helpers for struct layout differences |
| macOS Intel | gfortran | Works with -frecursive |
| macOS ARM64 | flang-new (LLVM) | Required -- gfortran has 7+ critical bugs. Auto-enables C string library. Install via brew install flang. |
The Makefile auto-detects your platform and selects the right compiler and flags. Just run make.
fortsh # Interactive mode
fortsh script.sh # Run a script
fortsh -c 'cmd' # Run a commandIt works like bash. If it doesn't, that's a bug.
Login shell reads: /etc/fortsh/profile, ~/.fortsh_profile
Interactive shell reads: /etc/fortsh/fortshrc, ~/.fortshrc
Logout runs: ~/.fortsh_logout
First run offers to create default configs. Or don't. I'm not your boss.
fish and zsh have some nice things. We have them too now.
Greyed-out suggestions appear as you type:
- History-based (commands you've run)
- Path-based (file/directory completions)
- Accept with Right Arrow (or Ctrl-E / End)
Typing an opener inserts its closer and leaves the cursor between the two:
echo ( # -> echo (|)
echo " # -> echo "|"
echo ${ # -> echo ${|}Pairs ( [ { " ' and `. Typing the closing character walks over
the one already there instead of doubling it, so you can type a line straight
through — echo $((6*7)) gives exactly that, not echo $((6*7)))). Backspace
with the cursor inside an empty pair removes both halves.
It stays out of the way when a pair would be wrong:
echo don't # apostrophe after a word char stays literal
echo \" # backslash-escaped opener stays literal
echo 'a(b' # nothing pairs inside single quotesAn opener typed directly in front of existing text inserts alone, so putting
the cursor before foo and typing ( gives (foo, not ()foo. Only closers
fortsh inserted itself are skipped over — a closer you typed by hand in front
of an unrelated one still inserts.
Autosuggestions keep working inside a pair. The pending closer is drawn behind
the suggestion, which carries its own — so echo "quo shows as
echo "quoted hello there", and accepting it (Right / Ctrl-E / End) does not
leave a stray quote behind. Accepting a single word (Alt-F) keeps the pair open
for the rest of the argument.
On by default. Turn it off with set +o autopair (in ~/.fortshrc to make it
stick).
Type a directory path, press Enter. That's it.
/tmp/ # Navigate to /tmp
../ # Go up
~/Documents/ # Go to ~/DocumentsWorks with Tab completion. Valid directories highlight green.
Directory navigation:
| Key | Action |
|---|---|
| Alt+Shift+Up | Go to parent directory |
| Alt+Shift+Left | Previous directory |
| Alt+Shift+Right | Next directory |
Fuzzy search (requires fzf):
| Key | Action |
|---|---|
| Ctrl-F | Search files |
| Alt-J | Search directories |
| Ctrl-H | Search history |
| Alt-G | Search git files |
Text selection (live since v1.7.0 — works like a GUI editor in the terminal):
| Key | Action |
|---|---|
| Shift+Left / Shift+Right | Extend selection by character |
| Shift+Home / Shift+End | Extend selection to line start / end |
| Shift+Up / Shift+Down | Extend selection line-wise (Home / End on single-line prompt) |
| Ctrl+Shift+Left / Ctrl+Shift+Right | Extend selection by word |
| Alt+Shift+B / Alt+Shift+F | Extend selection by word (emacs-native alias) |
| any plain motion (Left, Home, Alt+b, ...) | Collapse selection — char-motions snap to the appropriate edge |
| Ctrl+W or Ctrl+X | Cut selection (writes to kill buffer + system clipboard) |
| Alt+W | Copy selection (kill buffer + system clipboard, no delete) |
| Ctrl+Y | Paste from kill buffer (deletes selection first if active) |
| Ctrl+V | Paste from system clipboard (falls back to kill buffer if no tool) |
| typing a printable char | Replaces the selection in place (type-over) |
| Backspace / Delete | Removes the entire selection |
System clipboard bridge auto-detects pbcopy (macOS), wl-copy (Wayland), xclip or xsel (X11) at startup. If none are installed, cut/copy still work via the in-session kill buffer.
Env flags:
FORTSH_DEBUG_SELECTION=1— dump selection state to stderr on each mutationFORTSH_NO_BRACKETED_PASTE=1— disableESC[?2004hemit (terminal-compat triage)
Works for commands, paths, variables, and command-specific options.
Colors update as you type:
- Green = valid commands and directory paths
- Red = invalid commands
- Cyan = numbers
- Yellow = strings
- Grey = comments
Persists across sessions. Only saves interactive commands (not scripts or .fortshrc).
- Ctrl-R: search history
- Up/Down: navigate history
Configuration in ~/.fortshrc:
export HISTFILE=~/.fortsh_history
export HISTSIZE=1000
export HISTFILESIZE=2000
export HISTCONTROL=ignoredupsname="fortsh"
echo ${name} # fortsh
echo ${name:-default} # fortsh (or default if unset)
echo ${name%sh} # fort (remove shortest suffix match)path="/usr/local/bin/fortsh"
# Length
echo ${#path} # 21
# Substring
echo ${path:0:4} # /usr
# Remove prefix/suffix
echo ${path#*/} # usr/local/bin/fortsh
echo ${path##*/} # fortsh (remove longest prefix)
echo ${path%/*} # /usr/local/bin
echo ${path%%/*} # (remove longest suffix - empty)
# Replace
echo ${path/local/opt} # /usr/opt/bin/fortsh
echo ${path//o/0} # /usr/l0cal/bin/f0rtsh (replace all)
# Case conversion
text="Hello World"
echo ${text^^} # HELLO WORLD
echo ${text,,} # hello world
echo ${text^} # Hello World (first char)
# Indirect expansion
ref="path"
echo ${!ref} # /usr/local/bin/fortsh (value of $path)
echo ${!ref:-fallback} # works with modifiers too# Indexed arrays
fruits=(apple banana cherry)
echo ${fruits[0]} # apple
echo ${fruits[@]} # apple banana cherry
echo ${#fruits[@]} # 3
fruits+=(date) # append
echo ${fruits[@]:1:2} # banana cherry (slice)
# Associative arrays (yes, really)
declare -A config
config[host]=localhost
config[port]=8080
config[user]=admin
echo ${config[host]} # localhost
echo ${!config[@]} # host port user (keys)
echo ${#config[@]} # 3 (count)
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done# Compare directory listings
diff <(ls dir1) <(ls dir2)
# Multiple inputs
paste <(seq 1 5) <(seq 6 10)
# Output substitution
echo "test" | tee >(wc -c) >(wc -w) >/dev/null# Email parsing
if [[ "user@example.com" =~ ^([^@]+)@([^.]+)\.(.+)$ ]]; then
echo "User: ${BASH_REMATCH[1]}" # user
echo "Domain: ${BASH_REMATCH[2]}" # example
echo "TLD: ${BASH_REMATCH[3]}" # com
fi
# Version string parsing
version="v3.14.159-beta"
if [[ $version =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-(.+))?$ ]]; then
major=${BASH_REMATCH[1]} # 3
minor=${BASH_REMATCH[2]} # 14
patch=${BASH_REMATCH[3]} # 159
suffix=${BASH_REMATCH[5]} # beta
fiecho {1..10} # 1 2 3 4 5 6 7 8 9 10
echo {a..z} # a b c ... z
echo {1..20..2} # 1 3 5 7 9 11 13 15 17 19
echo {10..1..2} # 10 8 6 4 2
echo {a,b,c}{1,2} # a1 a2 b1 b2 c1 c2
# Practical use
mkdir -p project/{src,test,docs}/{main,utils}
touch file{1..100}.txtecho $'tab:\there' # tab: here
echo $'line1\nline2' # line1 (newline) line2
echo $'it\'s fine' # it's fine
echo $'\e[31mred\e[0m' # red (in color)x=5
y=3
echo $((x + y)) # 8
echo $((x * y)) # 15
echo $((x ** y)) # 125 (exponentiation)
echo $((x % y)) # 2 (modulo)
# C-style for loops
for ((i=0; i<5; i++)); do
echo "Count: $i"
done
# Multi-variable
for ((i=0, j=10; i<j; i++, j--)); do
echo "$i $j"
done
# Inline increment
count=0
echo $((count++)) # 0 (post-increment)
echo $count # 1# Basic heredoc
cat <<EOF
Line 1
Line 2 with $variables expanded
EOF
# Quoted delimiter (no expansion)
cat <<'EOF'
$variables not expanded
EOF
# Here string (shorthand)
grep pattern <<<"search this text"
# Indented heredoc
if true; then
cat <<-EOF
This leading tab is stripped
So is this one
EOF
fi# Capture output
current_dir=$(pwd)
file_count=$(ls | wc -l)
# Nested substitution
echo "Found $(grep pattern $(find . -name '*.txt') | wc -l) matches"
# Complex pipelines
ps aux | grep fortsh | grep -v grep | awk '{print $2}' | xargs kill
# Pipeline with error handling
command1 | command2 || echo "Pipeline failed with status $?"# Named coproc
coproc WORKER { while read line; do echo "processed: $line"; done; }
echo "hello" >&${WORKER[1]}
read result <&${WORKER[0]}
echo $result # processed: hello
# Brace group coproc
coproc { cat -n; }# Background job
sleep 10 &
bg_pid=$!
echo "Started job $bg_pid"
# List jobs
jobs
# Bring to foreground
fg %1
# Kill job
kill %1
# Wait for completion
wait $bg_pid
echo "Job completed with status $?"# Case with multiple patterns
case $input in
*.txt|*.md)
echo "Text file"
;;
[0-9]*)
echo "Starts with number"
;;
*)
echo "Something else"
;;
esac
# Until loop (less common)
count=0
until [ $count -eq 5 ]; do
echo $count
((count++))
done
# Nested loops with break/continue
for i in {1..3}; do
for j in {1..3}; do
[ $i -eq 2 ] && [ $j -eq 2 ] && continue
echo "$i,$j"
done
doneouter_var="global"
my_function() {
local outer_var="local" # Shadows global
local inner_var="only here"
echo $outer_var # local
return 42
}
my_function
exit_code=$? # 42
echo $outer_var # global
echo $inner_var # (empty - not in scope)# Trap signals
trap 'echo "Cleaning up..."; rm -f /tmp/tempfile; exit' INT TERM
# Trap ERR (on command failure)
trap 'echo "Command failed with exit code $?"' ERR
# Trap EXIT (always runs)
trap 'echo "Script finished"' EXIT
# Remove trap
trap - INT# File tests
[ -f file ] # Regular file
[ -d dir ] # Directory
[ -L link ] # Symbolic link
[ -r file ] # Readable
[ -w file ] # Writable
[ -x file ] # Executable
[ file1 -nt file2 ] # file1 newer than file2
# String tests with [[ ]]
[[ $str =~ pattern ]] # Regex match
[[ $str == *substring* ]] # Glob match
[[ -n $str ]] # Non-empty
[[ -z $str ]] # Empty
# Numeric comparisons
[ $a -eq $b ] # Equal
[ $a -lt $b ] # Less than
[ $a -ge $b ] # Greater or equal
# Logical operators
[[ $a == "x" && $b == "y" ]] # And
[[ $a == "x" || $b == "y" ]] # Or
[[ ! $a == "x" ]] # Notmake test-posix # POSIX compliance (~1 min)
make test-posix-full # all POSIX suites (~3 min)
make test-posix-quick # fast POSIX, skip coverage (~30s)
make test-bench # unit bench tests (memory pool, lexer, executor, C strings)
make test-all # everything including memory pool tests
make check # comprehensive build checksIndividual test suites:
./tests/builtins/run_builtin_tests.sh --verbose
./tests/builtins/integration/run_integration_tests.sh --verbose
./tests/builtins/test_stress.shInteractive PTY tests (Python/pexpect):
cd tests/interactive
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python run_tests.pyOr don't. Live dangerously.
All of them: :, ., break, cd, continue, echo, eval, exec, exit, export, getopts, hash, printf, pwd, read, readonly, return, set, shift, test/[, times, trap, type, ulimit, umask, unset, wait
The useful ones: [[, alias, bg, command, compgen, complete, coproc, declare, fc, fg, history, jobs, kill, let, local, printenv, shopt, source, unalias, which
config- manage config filesmemory- show memory statsperf- show performance metricshelp <builtin>- detailed help for any builtindefun- function definition helperpushd/popd/dirs- directory stackprevd/nextd- directory history (back / forward)abbr- fish-style abbreviations that expand as you type
Every builtin has detailed help: help cd, help export, help trap, etc.
Both Fortran compilers have issues on Apple Silicon. fortsh uses flang-new (LLVM) with C interop workarounds. The Makefile handles everything automatically.
Install flang-new via brew install flang. See COMPILER_NOTES.md for the full story on compiler bugs and workarounds.
Key differences from Linux builds:
- C string library auto-enabled (works around flang-new string buffer limitations)
- Platform-specific constants for signals, terminal I/O, file flags, and resource limits
- Builtin output uses C-level
write()to respect fd redirections (flang-new's Fortran I/O caches file descriptors)
- Slower than bash for large scripts (it's Fortran, not a miracle worker)
- Unicode support varies by system locale
- Will not make you coffee
src/
├── common/ # Types, errors, string pool, perf monitoring
├── system/ # OS interface (POSIX syscalls), signals
├── parsing/ # Lexer, grammar parser, AST, glob
├── execution/ # AST executor, builtins, job control, pipelines
├── scripting/ # Variables, expansion, control flow, completion
├── io/ # Readline (~13,000 lines), suggestions, syntax highlighting, fd redirection
├── c_interop/ # C FFI: string ops, fd wrapper, terminal size
└── fortsh.f90 # Main REPL loop
~63,000 lines of Fortran, fully self-contained with no external Fortran library dependencies.
Why not?
More seriously: started as "can you even do this in Fortran?" Turns out yes. Then it became "how far can this go?" Turns out pretty far.
It's actually usable now. We're as surprised as you are.
POSIX.1-2017 (IEEE Std 1003.1-2017) bash 5.x for extensions
Found a bug? Cool, file an issue. Want to add a feature? Check it's not already there (spoiler: it might be). Want to make it faster? Please do.
GPL-3.0. See LICENSE file.
Repository: https://github.com/FortranGoingOnForty/fortsh Issues: https://github.com/FortranGoingOnForty/fortsh/issues POSIX Shell Spec: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
Yes, it's really written in Fortran. Yes, it really works. No, we don't know why either.