diff --git a/dev/Cargo.toml b/dev/Cargo.toml
index aeec88e3..acb65200 100644
--- a/dev/Cargo.toml
+++ b/dev/Cargo.toml
@@ -16,3 +16,7 @@ object = "0.33"
 name = "nm"
 path = "src/nm.rs"
 
+[[bin]]
+name = "c99"
+path = "src/c99.rs"
+
diff --git a/dev/src/c99.rs b/dev/src/c99.rs
new file mode 100644
index 00000000..67bf8075
--- /dev/null
+++ b/dev/src/c99.rs
@@ -0,0 +1,124 @@
+//
+// Copyright (c) 2024 Jeff Garzik
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+extern crate clap;
+extern crate plib;
+
+mod libc99;
+
+use clap::Parser;
+use gettextrs::{bind_textdomain_codeset, textdomain};
+use libc99::CStream;
+use plib::PROJECT_NAME;
+use std::fs;
+use std::io;
+
+/// c99 - compile standard C programs
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about)]
+struct Args {
+    /// Suppress the link-edit phase of the compilation, and do not remove any object files that are produced.
+    #[arg(short, long)]
+    compile_only: bool,
+
+    /// Files to process
+    files: Vec<String>,
+}
+
+#[derive(Debug)]
+struct BuildPlan {
+    sources: Vec<String>,
+    objs: Vec<String>,
+}
+
+impl BuildPlan {
+    fn new() -> BuildPlan {
+        BuildPlan {
+            sources: Vec::new(),
+            objs: Vec::new(),
+        }
+    }
+
+    fn add_src(&mut self, filename: &str) {
+        self.sources.push(String::from(filename));
+    }
+
+    fn add_obj(&mut self, filename: &str) {
+        self.objs.push(String::from(filename));
+    }
+}
+
+fn has_src_ext(filename: &str) -> bool {
+    filename.ends_with(".c")
+}
+
+fn has_obj_ext(filename: &str) -> bool {
+    filename.ends_with(".o")
+}
+
+fn c99_plan(_args: &Args, plan: &mut BuildPlan, filearg: &str) -> io::Result<()> {
+    if has_src_ext(filearg) {
+        plan.add_src(filearg);
+    } else if has_obj_ext(filearg) {
+        plan.add_obj(filearg);
+    }
+
+    println!("{:?}", plan);
+
+    Ok(())
+}
+
+fn c99_build_one(_plan: &BuildPlan, filename: &String) -> io::Result<()> {
+    // read input source file into buffer
+    let bytes = fs::read(filename)?;
+
+    let mut stream = CStream::from_buffer(0, bytes);
+    stream.tokenize();
+
+    Ok(())
+}
+
+fn c99_build(plan: BuildPlan) -> io::Result<()> {
+    for src in &plan.sources {
+        c99_build_one(&plan, src)?;
+    }
+
+    Ok(())
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // parse command line arguments
+    let args = Args::parse();
+
+    textdomain(PROJECT_NAME)?;
+    bind_textdomain_codeset(PROJECT_NAME, "UTF-8")?;
+
+    let mut plan = BuildPlan::new();
+    let mut exit_code = 0;
+
+    for filearg in &args.files {
+        match c99_plan(&args, &mut plan, filearg) {
+            Ok(()) => {}
+            Err(e) => {
+                exit_code = 1;
+                eprintln!("{}: {}", filearg, e);
+            }
+        }
+    }
+
+    match c99_build(plan) {
+        Ok(()) => {}
+        Err(e) => {
+            exit_code = 1;
+            eprintln!("{}", e);
+        }
+    }
+
+    std::process::exit(exit_code)
+}
diff --git a/dev/src/libc99.rs b/dev/src/libc99.rs
new file mode 100644
index 00000000..0711fed3
--- /dev/null
+++ b/dev/src/libc99.rs
@@ -0,0 +1,563 @@
+//
+// Copyright (c) 2024 Jeff Garzik
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+const TABSTOP: i32 = 8;
+const EOF: i32 = -1;
+
+#[derive(Clone, Debug)]
+enum CTokenSpecial {
+    AddAssign,
+    DivAssign,
+    XorAssign,
+    LogicalAnd,
+    HashHash,
+    LeftShift,
+    LTE,
+    NotEqual,
+    ModAssign,
+    Decrement,
+    SubAssign,
+    Dereference,
+    Equal,
+    AndAssign,
+    MulAssign,
+    DotDot,
+    Increment,
+    OrAssign,
+    GTE,
+    LogicalOr,
+    RightShift,
+    ShlAssign,
+    ShrAssign,
+    Ellipsis,
+}
+
+enum CTokenType {
+    Invalid,
+    StreamBegin,
+    StreamEnd,
+    Number(String),
+    Str(bool, bool, String),
+    Ident(String),
+    Special(CTokenSpecial),
+    SpecialChar(char),
+}
+
+pub struct CToken {
+    ttype: CTokenType,
+}
+
+impl CToken {
+    fn new() -> CToken {
+        CToken {
+            ttype: CTokenType::Invalid,
+        }
+    }
+
+    fn new_type(ttype: CTokenType) -> CToken {
+        CToken { ttype }
+    }
+}
+
+pub struct CStream {
+    fd: i32,
+    offset: usize,
+    size: usize,
+    pos: i32,
+    line: i32,
+    nr: i32,
+    newline: i32,
+    whitespace: i32,
+
+    token: CToken,
+    tokenlist: Vec<CToken>,
+    buffer: Vec<u8>,
+}
+
+enum NcState {
+    Restart,
+    Repeat,
+    Norm,
+    Out,
+    CheckLF,
+    GotEOF,
+}
+
+const CC_DIGIT: u32 = 1 << 0;
+const CC_LETTER: u32 = 1 << 1;
+const CC_HEX: u32 = 1 << 2;
+const CC_EXP: u32 = 1 << 3;
+const CC_SECOND: u32 = 1 << 4;
+const CC_QUOTE: u32 = 1 << 5;
+const CC_DOT: u32 = 1 << 6;
+
+fn maybe_third_char(special: CTokenSpecial) -> Option<(u8, CTokenSpecial)> {
+    match special {
+        CTokenSpecial::LeftShift => Some((b'=', CTokenSpecial::ShlAssign)),
+        CTokenSpecial::RightShift => Some((b'=', CTokenSpecial::ShrAssign)),
+        CTokenSpecial::DotDot => Some((b'.', CTokenSpecial::Ellipsis)),
+        _ => None,
+    }
+}
+
+fn classify_special(c1: i32, c2: i32) -> Option<CTokenSpecial> {
+    match (c1 as u8, c2 as u8) {
+        (b'+', b'=') => Some(CTokenSpecial::AddAssign),
+        (b'/', b'=') => Some(CTokenSpecial::DivAssign),
+        (b'^', b'=') => Some(CTokenSpecial::XorAssign),
+        (b'&', b'&') => Some(CTokenSpecial::LogicalAnd),
+        (b'#', b'#') => Some(CTokenSpecial::HashHash),
+        (b'<', b'<') => Some(CTokenSpecial::LeftShift),
+        (b'<', b'=') => Some(CTokenSpecial::LTE),
+        (b'!', b'=') => Some(CTokenSpecial::NotEqual),
+        (b'%', b'=') => Some(CTokenSpecial::ModAssign),
+        (b'-', b'-') => Some(CTokenSpecial::Decrement),
+        (b'-', b'=') => Some(CTokenSpecial::SubAssign),
+        (b'-', b'>') => Some(CTokenSpecial::Dereference),
+        (b'=', b'=') => Some(CTokenSpecial::Equal),
+        (b'&', b'=') => Some(CTokenSpecial::AndAssign),
+        (b'*', b'=') => Some(CTokenSpecial::MulAssign),
+        (b'.', b'.') => Some(CTokenSpecial::DotDot),
+        (b'+', b'+') => Some(CTokenSpecial::Increment),
+        (b'|', b'=') => Some(CTokenSpecial::OrAssign),
+        (b'>', b'=') => Some(CTokenSpecial::GTE),
+        (b'|', b'|') => Some(CTokenSpecial::LogicalOr),
+        (b'>', b'>') => Some(CTokenSpecial::RightShift),
+        _ => None,
+    }
+}
+
+fn classify_char(c32: i32) -> u32 {
+    let ch = (c32 as u8) as char;
+
+    match ch {
+        '0'..='9' => CC_DIGIT | CC_HEX,
+        'A'..='D' => CC_LETTER | CC_HEX,
+        'E' => CC_LETTER | CC_HEX | CC_EXP, /* E<exp> */
+        'F' => CC_LETTER | CC_HEX,
+        'G'..='O' => CC_LETTER,
+        'P' => CC_LETTER | CC_EXP, /* P<exp> */
+        'Q'..='Z' => CC_LETTER,
+        'a'..='d' => CC_LETTER | CC_HEX,
+        'e' => CC_LETTER | CC_HEX | CC_EXP, /* e<exp> */
+        'f' => CC_LETTER | CC_HEX,
+        'g'..='o' => CC_LETTER,
+        'p' => CC_LETTER | CC_EXP, /* p<exp> */
+        'q'..='z' => CC_LETTER,
+        '_' => CC_LETTER,
+        '.' => CC_DOT | CC_SECOND,
+        '=' => CC_SECOND,
+        '+' => CC_SECOND,
+        '-' => CC_SECOND,
+        '>' => CC_SECOND,
+        '<' => CC_SECOND,
+        '&' => CC_SECOND,
+        '|' => CC_SECOND,
+        '#' => CC_SECOND,
+        '\'' => CC_QUOTE,
+        '"' => CC_QUOTE,
+        _ => 0,
+    }
+}
+
+impl CStream {
+    pub fn from_buffer(idx: i32, buf: Vec<u8>) -> CStream {
+        CStream {
+            fd: -1,
+            nr: idx,
+            line: 1,
+            newline: 1,
+            whitespace: 0,
+            pos: 0,
+            offset: 0,
+            size: buf.len(),
+            token: CToken::new(),
+            tokenlist: vec![CToken::new_type(CTokenType::StreamBegin)],
+            buffer: buf,
+        }
+    }
+
+    pub fn mark_eof(&mut self) {
+        let token = CToken::new_type(CTokenType::StreamEnd);
+        self.tokenlist.push(token);
+    }
+
+    pub fn nextchar_slow(&mut self) -> i32 {
+        let size = self.size;
+        let mut offset = self.offset;
+        let mut c: i32 = EOF;
+        let mut spliced = false;
+        let mut had_cr = false;
+        let mut had_backslash = false;
+        let mut state = NcState::Restart;
+
+        loop {
+            match state {
+                NcState::Restart => {
+                    had_cr = false;
+                    had_backslash = false;
+                    state = NcState::Repeat;
+                }
+                NcState::Repeat => {
+                    if offset >= size {
+                        if self.fd < 0 {
+                            state = NcState::GotEOF;
+                            continue;
+                        }
+
+                        panic!("reading files not implemented");
+                    }
+
+                    let ch = self.buffer[offset];
+                    c = ch as i32;
+                    offset += 1;
+                    if had_cr {
+                        state = NcState::CheckLF;
+                        continue;
+                    }
+
+                    if ch as char == '\r' {
+                        had_cr = true;
+                        continue; // state remains Repeat
+                    }
+
+                    state = NcState::Norm;
+                }
+                NcState::Norm => {
+                    if had_backslash {
+                        match (c as u8) as char {
+                            '\t' => {
+                                self.pos += TABSTOP - (self.pos % TABSTOP);
+                            }
+                            '\n' => {
+                                self.line += 1;
+                                self.pos = 0;
+                                self.newline = 1;
+                            }
+                            '\\' => {
+                                had_backslash = true;
+                                self.pos += 1;
+                                state = NcState::Repeat;
+                                continue;
+                            }
+                            _ => {
+                                self.pos += 1;
+                            }
+                        }
+                    } else {
+                        if (c as u8) as char == '\n' {
+                            self.line += 1;
+                            self.pos = 0;
+                            spliced = true;
+                            state = NcState::Restart;
+                            continue;
+                        }
+
+                        offset -= 1;
+                        c = ('\\' as u8) as i32;
+                    }
+
+                    state = NcState::Out;
+                }
+                NcState::Out => {
+                    self.offset = offset;
+                    return c;
+                }
+                NcState::CheckLF => {
+                    if (c as u8) as char != '\n' {
+                        offset -= 1;
+                    }
+                    c = ('\n' as u8) as i32;
+                    state = NcState::Norm;
+                }
+                NcState::GotEOF => {
+                    if had_backslash {
+                        c = ('\\' as u8) as i32;
+                        state = NcState::Out;
+                        continue;
+                    }
+
+                    // todo: warning no-newline-at-EOF
+
+                    return EOF;
+                }
+            }
+        }
+    }
+
+    pub fn nextchar(&mut self) -> i32 {
+        let mut offset = self.offset;
+        if offset < self.size {
+            let ch = self.buffer[offset];
+            let c = ch as i32;
+            offset += 1;
+
+            match ch as char {
+                '\t' | '\r' | '\n' | '\\' => {}
+                _ => {
+                    self.offset = offset;
+                    self.pos += 1;
+                    return c;
+                }
+            }
+        }
+
+        return self.nextchar_slow();
+    }
+
+    pub fn drop_comment(&mut self) -> i32 {
+        // todo: drop_token() -- adjust stream {newline,whitespace}
+
+        let newline = self.newline;
+        let mut next = self.nextchar();
+        loop {
+            let curr = next;
+            if curr < 0 {
+                // todo: warning EOF-in-comment
+                return curr;
+            }
+
+            next = self.nextchar();
+            if curr == (b'*' as i32) && next == (b'/' as i32) {
+                break;
+            }
+        }
+
+        self.newline = newline;
+
+        self.nextchar()
+    }
+
+    pub fn drop_eoln(&mut self) -> i32 {
+        // todo: drop_token() -- adjust stream {newline,whitespace}
+        loop {
+            let c = self.nextchar();
+            if c < 0 {
+                return c;
+            }
+
+            if (c as u8) as char == '\n' {
+                return self.nextchar();
+            }
+        }
+    }
+
+    pub fn eat_string(&mut self, mut next: i32, is_str: bool, is_wide: bool) -> i32 {
+        let delim = match is_str {
+            true => '"',
+            false => '\'',
+        };
+        let mut escape = false;
+        let mut want_hex = false;
+        let mut tmpstr = String::with_capacity(80);
+
+        while escape || next != delim as i32 {
+            tmpstr.push((next as u8) as char);
+            if next == b'\n' as i32 {
+                // todo -- warning -- unterminated string
+                break;
+            }
+            if next < 0 {
+                // todo -- warning -- EOF in string
+                return next;
+            }
+            if !escape {
+                if want_hex && classify_char(next) & CC_HEX != 0 {
+                    // todo -- warning -- no following hex digits
+                }
+                want_hex = false;
+                if next == b'\\' as i32 {
+                    escape = true;
+                }
+            } else {
+                escape = false;
+                if next == b'x' as i32 {
+                    want_hex = true;
+                }
+            }
+            next = self.nextchar();
+        }
+
+        if want_hex {
+            // todo -- warning -- no following hex digits
+        }
+
+        self.tokenlist.push(CToken {
+            ttype: CTokenType::Str(is_str, is_wide, tmpstr),
+        });
+
+        self.nextchar()
+    }
+
+    pub fn get_one_number(&mut self, c: i32, mut next: i32) -> i32 {
+        let mut numstr = String::with_capacity(80);
+
+        numstr.push((c as u8) as char);
+        loop {
+            let class = classify_char(next);
+            if (class & (CC_DOT | CC_DIGIT | CC_LETTER)) == 0 {
+                break;
+            }
+
+            numstr.push((next as u8) as char);
+            next = self.nextchar();
+            if (class & CC_EXP) != 0 {
+                let next_ch = (next as u8) as char;
+                if next_ch == '-' || next_ch == '+' {
+                    numstr.push(next_ch);
+                    next = self.nextchar();
+                }
+            }
+        }
+
+        self.tokenlist.push(CToken {
+            ttype: CTokenType::Number(numstr),
+        });
+
+        next
+    }
+
+    pub fn get_one_special(&mut self, c: i32) -> i32 {
+        let mut next = self.nextchar();
+        let next_ch = (next as u8) as char;
+        match (c as u8) as char {
+            '.' => {
+                if next_ch >= '0' && next_ch <= '9' {
+                    return self.get_one_number(c, next);
+                }
+            }
+            '"' => {
+                return self.eat_string(next, true, false);
+            }
+            '\'' => {
+                return self.eat_string(next, false, false);
+            }
+            '/' => {
+                if next_ch == '/' {
+                    return self.drop_eoln();
+                }
+                if next_ch == '*' {
+                    return self.drop_comment();
+                }
+            }
+            _ => {}
+        }
+
+        // process multi-character special token
+        let mask = classify_char(next);
+        if (mask & CC_SECOND) != 0 {
+            if let Some(special) = classify_special(c, next) {
+                match maybe_third_char(special.clone()) {
+                    // translate 2-char special into 3-char special
+                    Some((third, third_special)) => {
+                        next = self.nextchar();
+                        if next == third as i32 {
+                            self.tokenlist.push(CToken {
+                                ttype: CTokenType::Special(third_special),
+                            });
+                            return self.nextchar();
+                        }
+
+                        // fall through
+                    }
+
+                    None => {
+                        // fall through
+                        next = self.nextchar();
+                    }
+                }
+
+                // normal 2-char special
+                self.tokenlist.push(CToken {
+                    ttype: CTokenType::Special(special),
+                });
+
+                return next;
+            }
+        }
+
+        // process single-character special token
+        self.tokenlist.push(CToken {
+            ttype: CTokenType::SpecialChar((c as u8) as char),
+        });
+
+        next
+    }
+
+    pub fn get_one_identifier(&mut self, c: i32) -> i32 {
+        let mut buf = String::with_capacity(80);
+
+        buf.push((c as u8) as char);
+        let mut next;
+
+        loop {
+            next = self.nextchar();
+
+            let class = classify_char(next);
+            if (class & (CC_LETTER | CC_DIGIT)) == 0 {
+                break;
+            }
+
+            buf.push((next as u8) as char);
+        }
+
+        if (classify_char(next) & CC_QUOTE) != 0 {
+            if buf.len() == 1 && (buf.as_bytes()[0] as char) == 'L' {
+                let nextnext = self.nextchar();
+                if next == '"' as i32 {
+                    return self.eat_string(nextnext, true, true);
+                } else {
+                    return self.eat_string(nextnext, false, true);
+                }
+            }
+        }
+
+        self.tokenlist.push(CToken {
+            ttype: CTokenType::Ident(buf),
+        });
+
+        next
+    }
+
+    pub fn get_one_token(&mut self, c: i32) -> i32 {
+        let mask = classify_char(c);
+        assert!(mask != 0);
+
+        if (mask & CC_DIGIT) != 0 {
+            let c2 = self.nextchar();
+            return self.get_one_number(c, c2);
+        }
+        if (mask & CC_LETTER) != 0 {
+            return self.get_one_identifier(c);
+        }
+
+        return self.get_one_special(c);
+    }
+
+    pub fn tokenize(&mut self) {
+        let mut c = self.nextchar();
+        while c >= 0 {
+            let ch = (c as u8) as char;
+
+            if !ch.is_whitespace() {
+                self.token = CToken::new_type(CTokenType::Invalid);
+                self.newline = 0;
+                self.whitespace = 0;
+
+                c = self.get_one_token(c);
+                continue;
+            }
+
+            self.whitespace = 1;
+            c = self.nextchar();
+        }
+
+        self.mark_eof();
+    }
+}
