009 Project 9: Build a grep Clone
009 Build a grep Clone
Search text with regex, case-insensitive mode, and line numbers. Stream each file line-by-line and print matches in a stable, script-friendly format.
files -> scan line by line -> regex match -> print matches
Problem statement
Implement ggrep:
ggrep [flags] <pattern> <file...>
Flags:
-iignore case (prefix(?i)or compile with case-folding)-nshow line numbers asfile:line:text
Exit codes (grep-compatible style is nice-to-have):
0if any match1if no match2on usage/regex/IO hard failure (choose and document)
Acceptance criteria
- Matches printed for each matching line
-iworks for mixed-case patterns-nincludes line numbers- Invalid regex → clear error, exit 2
- Multiple files supported
- Streaming (no full-file regex required)
Setup
mkdir ggrep && cd ggrep
go mod init example.com/ggrep
# go 1.27Full main.go
package main
import (
"bufio"
"flag"
"fmt"
"os"
"regexp"
)
func grepFile(path string, re *regexp.Regexp, showLineNo bool) (matched bool, err error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
s := bufio.NewScanner(f)
buf := make([]byte, 0, 64*1024)
s.Buffer(buf, 1024*1024)
lineNo := 0
for s.Scan() {
lineNo++
line := s.Text()
if !re.MatchString(line) {
continue
}
matched = true
if showLineNo {
fmt.Printf("%s:%d:%s\n", path, lineNo, line)
} else {
fmt.Printf("%s:%s\n", path, line)
}
}
return matched, s.Err()
}
func main() {
ignoreCase := flag.Bool("i", false, "ignore case")
showLineNo := flag.Bool("n", false, "show line number")
flag.Parse()
if flag.NArg() < 2 {
fmt.Fprintln(os.Stderr, "usage: ggrep [flags] <pattern> <file...>")
os.Exit(2)
}
pattern := flag.Arg(0)
if *ignoreCase {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
fmt.Fprintln(os.Stderr, "invalid regex:", err)
os.Exit(2)
}
any := false
hadErr := false
for _, file := range flag.Args()[1:] {
matched, err := grepFile(file, re, *showLineNo)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", file, err)
hadErr = true
continue
}
if matched {
any = true
}
}
if hadErr {
os.Exit(2)
}
if !any {
os.Exit(1)
}
}Step-by-step build path
- Parse flags; require pattern + ≥1 file.
- Compile regex once (with optional
(?i)). - Per file: open, scan, match, print.
- Track
anymatch and IO errors for exit codes. - Raise scanner buffer for long lines.
Run and verification
printf 'Error: boom\ninfo ok\nERROR again\n' > app.log
go run . error app.log
go run . -i -n "timeout|error" app.log
# no match → exit 1
go run . zzzzz app.log; echo exit:$?Tests
package main
import (
"os"
"path/filepath"
"regexp"
"testing"
)
func TestGrepFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.log")
if err := os.WriteFile(path, []byte("alpha\nBeta\nalpha2\n"), 0o644); err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`(?i)alpha`)
ok, err := grepFile(path, re, true)
if err != nil || !ok {
t.Fatalf("matched=%v err=%v", ok, err)
}
}go test -race ./...Stretch goals
- Recursive directory walk (
-r) usingfilepath.WalkDir. - Invert match (
-v). - Count only (
-c). - Colorize matches on TTY.
- Read stdin when files omitted.
Pitfalls
| Pitfall | Fix |
|---|---|
| Compiling regex per line | Compile once |
| Shell globs not expanded by Go | Pass files or implement walk |
| Catastrophic backtracking | Prefer simpler patterns; timeouts hard in stdlib |
| Binary files spam | Detect NUL and skip (stretch) |
Learning goals
regexppackage and RE2 flavor limits- Streaming text tools
- Grep-compatible exit codes for scripts