Skip to main content
Nash implements all shell commands as built-in Rust functions — no external binaries are ever executed. This page explains how the builtin system works and how commands are implemented.

Module Structure

builtins/
├── mod.rs          # Builtin trait + dispatch table
├── cat.rs          # Concatenate and print files
├── cd.rs           # Change directory
├── clear.rs        # Clear screen
├── cp.rs           # Copy files
├── cut.rs          # Cut fields from lines
├── echo.rs         # Print text
├── env.rs          # Environment variables (env, export, unset)
├── file.rs         # Detect file type
├── find.rs         # Search for files
├── grep.rs         # Filter lines
├── head_tail.rs    # First/last N lines (head, tail)
├── help.rs         # Command reference
├── history.rs      # Command history
├── jq.rs           # JSON processor
├── ls.rs           # List directory
├── mkdir.rs        # Create directory
├── mv.rs           # Move/rename files
├── pwd.rs          # Print working directory
├── rm.rs           # Remove files
├── sed.rs          # Stream editor
├── sort.rs         # Sort lines
├── stat.rs         # File status
├── touch.rs        # Create empty file
├── tree.rs         # Directory tree view
├── uniq.rs         # Filter duplicate lines
├── util.rs         # Utilities (true, false, test)
├── wc.rs           # Word count
└── which.rs        # Locate command
Nash includes 28 built-in commands covering file operations, text processing, and shell utilities.

Builtin Trait

Every command implements the same trait (src/builtins/mod.rs:38-41):
pub trait Builtin {
    fn run(&self, args: &[String], ctx: &mut Context, stdin: &str) -> Result<Output>;
}
Parameters:
  • args — Command-line arguments (already expanded)
  • ctx — Mutable session context (cwd, env, VFS, history)
  • stdin — Standard input as a string
Returns:
  • Result<Output> — Success with Output { stdout, stderr, exit_code }

Dispatch Table

From src/builtins/mod.rs:44-81:
pub fn dispatch(name: &str) -> Option<Box<dyn Builtin>> {
    match name {
        "cat" => Some(Box::new(cat::Cat)),
        "cd" => Some(Box::new(cd::Cd)),
        "clear" => Some(Box::new(clear::Clear)),
        "cp" => Some(Box::new(cp::Cp)),
        "cut" => Some(Box::new(cut::Cut)),
        "echo" => Some(Box::new(echo::Echo)),
        "env" => Some(Box::new(env::EnvCmd)),
        "export" => Some(Box::new(env::Export)),
        "unset" => Some(Box::new(env::Unset)),
        "file" => Some(Box::new(file::FileCmd)),
        "find" => Some(Box::new(find::Find)),
        "grep" => Some(Box::new(grep::Grep)),
        "head" => Some(Box::new(head_tail::Head)),
        "tail" => Some(Box::new(head_tail::Tail)),
        "help" => Some(Box::new(help::Help)),
        "history" => Some(Box::new(history::History)),
        "jq" => Some(Box::new(jq::Jq)),
        "ls" => Some(Box::new(ls::Ls)),
        "mkdir" => Some(Box::new(mkdir::Mkdir)),
        "mv" => Some(Box::new(mv::Mv)),
        "pwd" => Some(Box::new(pwd::Pwd)),
        "rm" => Some(Box::new(rm::Rm)),
        "sed" => Some(Box::new(sed::Sed)),
        "sort" => Some(Box::new(sort::Sort)),
        "stat" => Some(Box::new(stat::Stat)),
        "touch" => Some(Box::new(touch::Touch)),
        "tree" => Some(Box::new(tree::Tree)),
        "true" => Some(Box::new(util::True)),
        "false" => Some(Box::new(util::False)),
        "test" | "[" => Some(Box::new(util::Test)),
        "uniq" => Some(Box::new(uniq::Uniq)),
        "wc" => Some(Box::new(wc::Wc)),
        "which" => Some(Box::new(which::Which)),
        _ => None,
    }
}
The runtime calls dispatch(command_name) and executes the returned trait object.

Example Implementations

Simple Command: Echo

From src/builtins/echo.rs:5-35:
pub struct Echo;

impl Builtin for Echo {
    fn run(&self, args: &[String], _ctx: &mut Context, _stdin: &str) -> Result<Output> {
        let mut no_newline = false;
        let mut parts: Vec<&str> = Vec::new();
        
        let mut iter = args.iter();
        while let Some(arg) = iter.next() {
            if arg == "-n" {
                no_newline = true;
            } else if arg == "-e" {
                // Enable escape interpretation (handled below)
            } else {
                parts.push(arg.as_str());
            }
        }
        
        let text = parts.join(" ");
        let expanded = expand_escapes(&text);  // Handle \n, \t, etc.
        
        let out = if no_newline {
            expanded
        } else {
            format!("{}\n", expanded)
        };
        
        Ok(Output::success(out))
    }
}
Key points:
  • Parse flags (-n, -e)
  • Join remaining args with spaces
  • Expand escape sequences
  • Return output with optional newline

File Reader: Cat

From src/builtins/cat.rs:6-39:
pub struct Cat;

impl Builtin for Cat {
    fn run(&self, args: &[String], ctx: &mut Context, stdin: &str) -> Result<Output> {
        if args.is_empty() {
            // No args: pass through stdin
            return Ok(Output::success(stdin));
        }
        
        let mut out = String::new();
        for arg in args {
            if arg.starts_with('-') {
                continue;  // Ignore flags
            }
            
            // Resolve path relative to cwd
            let abs = VfsPath::join(&ctx.cwd, arg);
            
            // Read from VFS
            match ctx.vfs.read_to_string(&abs) {
                Ok(content) => out.push_str(&content),
                Err(e) => {
                    return Ok(Output::error(
                        1,
                        "",
                        &format!("cat: {}: {}\n", arg, e),
                    ));
                }
            }
        }
        
        Ok(Output::success(out))
    }
}
Key points:
  • If no args, return stdin (pipe pass-through)
  • Join path with cwd to get absolute path
  • Read file from VFS
  • Accumulate output from multiple files

Text Filter: Grep

From src/builtins/grep.rs:6-105:
pub struct Grep;

impl Builtin for Grep {
    fn run(&self, args: &[String], ctx: &mut Context, stdin: &str) -> Result<Output> {
        let mut invert = false;
        let mut ignore_case = false;
        let mut line_number = false;
        let mut pattern: Option<String> = None;
        let mut files: Vec<String> = Vec::new();
        
        // Parse arguments
        for arg in args {
            match arg.as_str() {
                "-v" | "--invert-match" => invert = true,
                "-i" | "--ignore-case" => ignore_case = true,
                "-n" | "--line-number" => line_number = true,
                s if s.starts_with('-') => {} // Ignore unknown flags
                _ => {
                    if pattern.is_none() {
                        pattern = Some(arg.clone());
                    } else {
                        files.push(arg.clone());
                    }
                }
            }
        }
        
        let pat = match pattern {
            Some(p) => p,
            None => return Ok(Output::error(1, "", "grep: missing pattern\n")),
        };
        
        // Read input (stdin or files)
        let text = if files.is_empty() {
            stdin.to_string()
        } else {
            let mut buf = String::new();
            for f in &files {
                let abs = VfsPath::join(&ctx.cwd, f);
                match ctx.vfs.read_to_string(&abs) {
                    Ok(c) => buf.push_str(&c),
                    Err(e) => return Ok(Output::error(1, "", &format!("grep: {}\n", e))),
                }
            }
            buf
        };
        
        // Filter lines
        let mut out = String::new();
        let mut matched = false;
        for (i, line) in text.lines().enumerate() {
            let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
            let needle = if ignore_case { pat.to_lowercase() } else { pat.clone() };
            let found = haystack.contains(&needle);
            let show = if invert { !found } else { found };
            
            if show {
                matched = true;
                if line_number {
                    out.push_str(&format!("{}:{}\n", i + 1, line));
                } else {
                    out.push_str(line);
                    out.push('\n');
                }
            }
        }
        
        if matched {
            Ok(Output::success(out))
        } else {
            Ok(Output::error(1, "", ""))  // Exit 1 if no matches
        }
    }
}
Key points:
  • Parse flags and collect pattern + files
  • Read from stdin or files
  • Line-by-line filtering with case and invert support
  • Return exit code 1 if no matches found

Common Patterns

1. Flag Parsing

let mut flag = false;
let mut positional = Vec::new();

for arg in args {
    match arg.as_str() {
        "-f" | "--flag" => flag = true,
        s if s.starts_with('-') => {} // Ignore unknown
        _ => positional.push(arg.clone()),
    }
}

2. Path Resolution

use crate::vfs::path::VfsPath;

let relative_path = "file.txt";
let absolute_path = VfsPath::join(&ctx.cwd, relative_path);

3. VFS Operations

// Read file
let content = ctx.vfs.read_to_string(&path)?;

// Write file
ctx.vfs.write_str(&path, "data")?;

// Check existence
if ctx.vfs.exists(&path) { /* ... */ }

// List directory
let entries = ctx.vfs.list_dir(&path)?;
for entry in entries {
    println!("{}", entry.name);
}

4. Environment Access

// Read variable
let home = ctx.env.get("HOME").cloned().unwrap_or_default();

// Set variable
ctx.env.insert("MY_VAR".to_string(), "value".to_string());

// Remove variable
ctx.env.remove("MY_VAR");

5. Stdin Handling

if args.is_empty() {
    // Process stdin
    for line in stdin.lines() {
        // ...
    }
} else {
    // Process files
    for file in args {
        let content = ctx.vfs.read_to_string(file)?;
        // ...
    }
}

6. Error Handling

match ctx.vfs.read(&path) {
    Ok(data) => { /* process data */ }
    Err(e) => {
        return Ok(Output::error(
            1,
            "",
            &format!("command: {}: {}\n", path, e),
        ));
    }
}

Complete Builtin List

Nash includes these 28 built-in commands:
CommandDescription
catConcatenate and print files
cpCopy files
lsList directory contents
mkdirCreate directory
mvMove/rename files
rmRemove files/directories
touchCreate empty file
statDisplay file status
fileDetect file type
findSearch for files
CommandDescription
echoPrint text
catPass-through stdin or print files
grepFilter lines by pattern
sedStream editor (substitute, delete)
cutExtract fields from lines
sortSort lines
uniqFilter duplicate lines
headFirst N lines
tailLast N lines
wcCount lines/words/bytes
CommandDescription
cdChange directory
pwdPrint working directory
envList environment variables
exportSet environment variable
unsetRemove environment variable
historyShow command history
whichCheck if command is builtin
helpDisplay command reference
clearClear screen
CommandDescription
jqJSON processor (.key, keys, values, length, type, .[])
treeDirectory tree view
test / [Evaluate conditional expressions
CommandDescription
trueExit with code 0
falseExit with code 1
testEvaluate expressions (-f, -d, -e, -z, -n, =, -eq)

Adding a New Builtin

To add a new command:

1. Create the module file

// src/builtins/mycommand.rs
use super::Builtin;
use crate::runtime::{Context, Output};
use crate::vfs::path::VfsPath;
use anyhow::Result;

pub struct MyCommand;

impl Builtin for MyCommand {
    fn run(&self, args: &[String], ctx: &mut Context, stdin: &str) -> Result<Output> {
        // Your implementation here
        Ok(Output::success("Hello from my command\n"))
    }
}

2. Register in mod.rs

// src/builtins/mod.rs
mod mycommand;

pub fn dispatch(name: &str) -> Option<Box<dyn Builtin>> {
    match name {
        // ... existing commands ...
        "mycommand" => Some(Box::new(mycommand::MyCommand)),
        _ => None,
    }
}

3. Test your command

#[cfg(test)]
mod tests {
    use crate::parser::parse;
    use crate::runtime::{Executor, ExecutorConfig};
    
    #[test]
    fn test_mycommand() {
        let mut executor = Executor::new(ExecutorConfig::default(), "user").unwrap();
        let out = executor.execute(&parse("mycommand").unwrap()).unwrap();
        assert_eq!(out.stdout, "Hello from my command\n");
    }
}

Design Decisions

1. Trait-Based Dispatch

Using a trait allows:
  • Uniform interface for all commands
  • Easy testing (mock implementations)
  • Dynamic dispatch via Box<dyn Builtin>

2. One File Per Command

Each command lives in its own file:
  • Clear organization
  • Easy to find implementation
  • No giant monolithic file

3. Mutable Context

Commands receive &mut Context to:
  • Modify environment (export, unset)
  • Change directory (cd)
  • Access VFS for reads/writes

4. String-Based I/O

All stdin/stdout is String, not Vec<u8>:
  • Simplifies text processing
  • Matches bash behavior (text-oriented)
  • Binary data still supported via VFS Vec<u8>

Testing

Builtins are tested end-to-end via the runtime (src/runtime/executor.rs:291-715):
#[test]
fn test_grep_invert() {
    let out = exec("echo hello | grep -v world");
    assert_eq!(out.stdout, "hello\n");
}

#[test]
fn test_find_basic() {
    let mut executor = Executor::new(ExecutorConfig::default(), "user").unwrap();
    executor.execute(&parse("touch /tmp/findme.txt").unwrap()).unwrap();
    let out = executor.execute(&parse("find /tmp -name findme.txt").unwrap()).unwrap();
    assert!(out.stdout.contains("findme.txt"));
}

Next Steps

Build docs developers (and LLMs) love