From 0a6e52694505c3844358693f0bbf6785a4f9100b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Thu, 27 Aug 2026 13:56:21 +0200 Subject: [PATCH] Add -N/--no-line-number flags and the noop -n flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -n flag is added to make us comåpatible with grep/ripgrep and others, to avoid Claude getting confused. --- README.md | 20 +++++++------ main.go | 55 ++++++++++++++++++++++++------------ testscripts/line_numbers.txt | 26 +++++++++++++++++ 3 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 testscripts/line_numbers.txt diff --git a/README.md b/README.md index 5ac3f53..e5c54c7 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,19 @@ A small and [fast](#benchmark) recursive grep written in Go. Install with `go install github.com/bep/grrep@latest`. ``` -usage: grrep [-q] [-F] [-i] [-w] [-v] [-d N] [--hidden] [--no-ignore] PATTERN [PATH] +usage: grrep [-q] [-F] [-i] [-w] [-v] [-n] [-N] [-d N] [--hidden] [--no-ignore] PATTERN [PATH] Flags: - -F treat PATTERN as a fixed string, not a regex - -i case-insensitive match - -d, --max-depth=N search at most N directory levels (1 = root only, 0 = nothing) - --hidden search hidden files and directories (.git is always skipped) - --no-ignore do not respect .gitignore/.ignore files - -q quiet: suppress match output - -v select non-matching lines - -w match only at word boundaries + -F treat PATTERN as a fixed string, not a regex + -i case-insensitive match + -d, --max-depth=N search at most N directory levels (1 = root only, 0 = nothing) + --hidden search hidden files and directories (.git is always skipped) + -n show line numbers (the default; accepted for grep compatibility) + -N, --no-line-number suppress line numbers + --no-ignore do not respect .gitignore/.ignore files + -q quiet: suppress match output + -v select non-matching lines + -w match only at word boundaries ``` ## Why diff --git a/main.go b/main.go index 0f002c4..f57d71e 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,15 @@ func main() { } } +// emit writes one match line as path:num:line, or path:line with -N. +func (g *grepper) emit(out *bytes.Buffer, path string, lineNum int, line []byte) { + if g.noLineNum { + fmt.Fprintf(out, "%s:%s\n", path, line) + return + } + fmt.Fprintf(out, "%s:%d:%s\n", path, lineNum, line) +} + // trimCR strips a trailing carriage return from line. Combined with the // per-scan-path \n stripping, this normalizes both LF and CRLF line endings // so output and regex matches don't see a stray \r at the end. @@ -77,16 +86,17 @@ func writeProfile(name, path string) { } type grepper struct { - m *internal.Matcher - root string - quiet bool - invert bool // -v: emit non-matching lines instead - hidden bool // --hidden: descend into dot-dirs/files (.git is always skipped) - maxDepth int // 0 = unlimited; passed through to fastwalk.Config - ctx context.Context - paths chan string - results chan []byte - ignores *internal.IgnoreSet // nil if --no-ignore + m *internal.Matcher + root string + quiet bool + invert bool // -v: emit non-matching lines instead + hidden bool // --hidden: descend into dot-dirs/files (.git is always skipped) + noLineNum bool // -N: emit path:line instead of path:num:line + maxDepth int // 0 = unlimited; passed through to fastwalk.Config + ctx context.Context + paths chan string + results chan []byte + ignores *internal.IgnoreSet // nil if --no-ignore numWorkersDirWalker int numWorkersFileScanner int @@ -99,6 +109,8 @@ func run() (bool, error) { opts internal.MatchOpts invert bool hidden bool + lineNum bool + noLineNum bool maxDepth int cpuProfile string memProfile string @@ -110,6 +122,9 @@ func run() (bool, error) { flag.BoolVar(&opts.CaseInsensitive, "i", false, "case-insensitive match") flag.BoolVar(&opts.WordBoundary, "w", false, "match only at word boundaries") flag.BoolVar(&invert, "v", false, "select non-matching lines") + flag.BoolVar(&lineNum, "n", false, "show line numbers (the default; accepted for grep compatibility)") + flag.BoolVar(&noLineNum, "no-line-number", false, "suppress line numbers") + flag.BoolVar(&noLineNum, "N", false, "") // alias for --no-line-number; suppressed in -h, paired with it below flag.BoolVar(&hidden, "hidden", false, "search hidden files and directories (.git is always skipped)") flag.IntVar(&maxDepth, "max-depth", -1, "search at most N directory levels (1 = root only, 0 = nothing)") flag.IntVar(&maxDepth, "d", -1, "") // alias for --max-depth; suppressed in -h, paired with it below @@ -119,7 +134,7 @@ func run() (bool, error) { flag.StringVar(&mutexProfile, "profile-mutex", "", "") flag.Usage = func() { out := flag.CommandLine.Output() - fmt.Fprintln(out, "usage: grrep [-q] [-F] [-i] [-w] [-v] [-d N] [--hidden] [--no-ignore] PATTERN [PATH]") + fmt.Fprintln(out, "usage: grrep [-q] [-F] [-i] [-w] [-v] [-n] [-N] [-d N] [--hidden] [--no-ignore] PATTERN [PATH]") fmt.Fprintln(out) fmt.Fprintln(out, "Flags:") flag.VisitAll(func(f *flag.Flag) { @@ -132,17 +147,20 @@ func run() (bool, error) { name = "--" + f.Name } // Pair known short aliases with their long form. - if f.Name == "max-depth" { + switch f.Name { + case "max-depth": name = "-d, " + name + "=N" + case "no-line-number": + name = "-N, " + name } - fmt.Fprintf(out, " %-18s %s\n", name, f.Usage) + fmt.Fprintf(out, " %-21s %s\n", name, f.Usage) }) } flag.Parse() args := flag.Args() if len(args) < 1 { - return false, fmt.Errorf("usage: grrep [-q] [-F] [-i] [-w] [-v] [-d N] [--hidden] [--no-ignore] PATTERN [PATH]") + return false, fmt.Errorf("usage: grrep [-q] [-F] [-i] [-w] [-v] [-n] [-N] [-d N] [--hidden] [--no-ignore] PATTERN [PATH]") } root := "." if len(args) >= 2 { @@ -194,6 +212,7 @@ func run() (bool, error) { quiet: quiet, invert: invert, hidden: hidden, + noLineNum: noLineNum, maxDepth: maxDepth, ctx: gCtx, paths: make(chan string, 256), @@ -401,7 +420,7 @@ func (g *grepper) scanWholeBody(path string, data []byte) []byte { if g.quiet { return []byte{} } - fmt.Fprintf(&out, "%s:%d:%s\n", path, lineNum, line) + g.emit(&out, path, lineNum, line) } // Advance past this line so we don't re-match on it. cursor = lineEnd @@ -442,7 +461,7 @@ func (g *grepper) scanWholeRegex(path string, data []byte) []byte { // Multiple regex hits can land on the same line — emit the line once. if lineEnd != prevLineEnd { line := trimCR(data[lineStart:lineEnd]) - fmt.Fprintf(&out, "%s:%d:%s\n", path, lineNum, line) + g.emit(&out, path, lineNum, line) prevLineEnd = lineEnd } cursor = matchPos @@ -467,7 +486,7 @@ func (g *grepper) scanInverted(path string, data []byte) []byte { if g.quiet { return []byte{} } - fmt.Fprintf(&out, "%s:%d:%s\n", path, lineNum, line) + g.emit(&out, path, lineNum, line) } start = end + 1 lineNum++ @@ -508,7 +527,7 @@ func (g *grepper) scanFileStream(path string, f *os.File) []byte { if g.quiet { return []byte{} } - fmt.Fprintf(&out, "%s:%d:%s\n", path, lineNum, line) + g.emit(&out, path, lineNum, line) } } if err != nil { diff --git a/testscripts/line_numbers.txt b/testscripts/line_numbers.txt new file mode 100644 index 0000000..cb31e32 --- /dev/null +++ b/testscripts/line_numbers.txt @@ -0,0 +1,26 @@ +# -n is a no-op: line numbers are always on. +grrep -n BAR +stdout '^\./words.txt:3:BAR$' + +# -N suppresses line numbers. +grrep -N BAR +stdout '^\./words.txt:BAR$' +! stdout ':3:' + +# Long form. +grrep --no-line-number BAR +stdout '^\./words.txt:BAR$' + +# -N with regex path. +grrep -N 'BA[RZ]' +stdout '^\./words.txt:BAZ$' +stdout '^\./words.txt:BAR$' + +# -N with invert. +grrep -N -v 'BA' +stdout '^\./words.txt:FOO$' + +-- words.txt -- +BAZ +FOO +BAR