Node-aware grep for markdown.
grep gives you a line. Markdown is made of bullets, headings, code fences,
quotes and tables — so mdgrep gives you the whole node the hit landed in.
$ mdgrep "brew install" notes.md
notes.md
Deployment › Prerequisites
13 │ - On macOS run `brew install foo`
Matched characters are highlighted, and the heading trail says where you are.
go install github.com/riadafridishibly/mdgrep@latestOr from a clone:
go install . # or: go build -o mdgrep .Go 1.26+, one dependency (goldmark).
mdgrep [OPTIONS] PATTERN [PATH...]
PATTERN is a regexp by default and is required — an empty one matches
everything, so mdgrep "" docs --todo lists every open checkbox under docs/.
Paths may be files or directories. Directories are walked for .md,
.markdown, .mdown, .mkd, .mdx. With no path, mdgrep reads stdin when it
is a pipe, otherwise it searches the current directory.
| Flag | Meaning |
|---|---|
-e, --regexp PATTERN |
use PATTERN as the pattern; repeat for alternatives |
-F, --fixed-strings |
match PATTERN literally |
--fuzzy |
fuzzy match |
--min-score N |
fuzzy threshold, 0..1 (default 0.7) |
--anchor |
PATTERN is a heading link anchor |
--anchor-style LIST |
anchor conventions to try (default all) |
-w, --word-regexp |
match only whole words |
-v, --invert-match |
select the nodes that do not match |
-i, --ignore-case |
force case-insensitive |
-s, --case-sensitive |
force case-sensitive |
-S, --smart-case |
case-insensitive until the pattern has an upper-case letter (default) |
Nodes match against the markdown as written, so structure is searchable and
^/$ anchor to lines:
mdgrep '^## ' # every second-level heading
mdgrep '^\| .*canary' # table rows mentioning the canary
mdgrep -F '**bold**' # the emphasis markers themselves--fuzzy wants every whitespace-separated token to appear in order, loosely:
pmd finds parseMarkDown, dk finds deploy_key. Results come back best
first rather than in file order, so -m keeps the best hits, not the first.
mdgrep --fuzzy "brew instal" notes.md # misspelled, still matches[see](#the-foo-bar) points at ## The Foo Bar. --anchor searches that way
round — give it the link, get the heading.
mdgrep --anchor "#the-foo-bar" docs
mdgrep --anchor "#the-foo-bar" docs --section # print the whole sectionWrite it as the-foo-bar, #the-foo-bar, ## The Foo Bar, or a whole link
like docs/setup.md#install — when the link names a file, only that file is
searched. Percent escapes are decoded.
Generators disagree about slugs, so mdgrep tries every convention it knows and
matches if any agrees. --anchor-style narrows the list:
| Style | ## Deploy & Rollback! |
## 1. Getting Started |
## Café Notes |
|---|---|---|---|
github |
deploy--rollback |
1-getting-started |
café-notes |
gitlab |
deploy-rollback |
1-getting-started |
café-notes |
python (MkDocs) |
deploy-rollback |
1-getting-started |
cafe-notes |
kramdown (Jekyll) |
deploy--rollback |
getting-started |
caf-notes |
pandoc |
deploy-rollback |
getting-started |
café-notes |
loose |
deploy-rollback |
1-getting-started |
cafe-notes |
Repeats are numbered, so the second ## Notes is #notes-1. --anchor names
its heading outright, so it takes no case flag and no -F, --fuzzy, -w
or -v.
| Flag | Meaning |
|---|---|
-k, --kind LIST |
heading, item (or bullet, li), list, paragraph, code, quote, table, row, cell, html, frontmatter |
--task |
only task list items |
--unchecked, --todo |
only unticked task items |
--checked, --done |
only ticked task items |
A filter never stands in for the pattern — pass an empty one to select by filter alone:
mdgrep "rollback" docs -k heading # only headings
mdgrep "deploy key" notes.md --unchecked # open work mentioning the deploy key
mdgrep "" docs --todo # every open box under docs/A hit in a plain sub-bullet reports the checkbox item it hangs under.
You get the matched node: a hit in a bullet's text lifts to the whole bullet and its children, a hit in a fenced block prints the fences.
| Flag | Meaning |
|---|---|
--expand N |
climb N ancestor levels from the matched node |
--section |
widen to the enclosing heading section |
--section-body |
that section without its heading line |
-B, --before N |
include N sibling blocks before |
-A, --after N |
include N sibling blocks after |
-C, --context N |
shorthand for -B N -A N |
--lines N |
pad with N raw lines on each side |
-B/-A/-C count blocks, not lines; use --lines for raw lines.
mdgrep "brew install" notes.md # just the nested bullet
mdgrep "brew install" notes.md --expand 1 # its parent bullet, with siblings
mdgrep "brew install" notes.md --section # the whole section
mdgrep "canary" notes.md -C2 # two blocks either sideOnly the matched node is highlighted, so it stays obvious what hit.
The flags that decide what gets printed decide what gets rewritten. Narrow the search until it selects the node you mean, then say what to do with it.
| Flag | Meaning |
|---|---|
--check / --uncheck / --toggle |
set the state of the selected task item |
--replace TEXT |
replace the selected region with TEXT |
--set-text TEXT |
change what the node says, keeping its markup |
--delete |
remove the selected region |
--append TEXT / --prepend TEXT |
insert TEXT after or before it |
--replace-from FILE and friends |
the same, with TEXT read from a file (- is stdin) |
--multi |
edit every match |
--expect N |
edit only if exactly N nodes matched |
--dry-run |
show the edit, write nothing |
mdgrep "ship the docs" --check # - [ ] ship the docs -> - [x]
mdgrep --anchor "#setup" --set-text "Install" # ## Setup -> ## Install
mdgrep "^## Changelog" --section-body --replace-from new.md
mdgrep "obsolete note" --delete--check, --uncheck, --toggle and --set-text act on the matched
node; --replace, --delete, --append and --prepend act on the
region that --section, --section-body and --expand widen. --set-text
keeps the markup that makes a node what it is — heading level, list marker,
checkbox, fences — where --replace keeps nothing. Inserted text is indented
to match what it lands beside, and blank lines are added only where they will
not loosen a list or break a table.
More than one match is an error. Nothing is written, and you get the list:
$ mdgrep "ship" --check notes.md
mdgrep: 2 matches; narrow the search or pass --multi
notes.md:5: - [ ] ship the docs
notes.md:7: - [ ] ship the tests
--multi edits them all. --expect N is the safer version — say how many you
believe there are, and any other number is refused:
$ mdgrep "ship" --check notes.md --expect 3
mdgrep: --expect 3, but 2 matched
Each of the four text edits also has a -from spelling (--replace-from,
--set-text-from, --append-from, --prepend-from) reading from a file, or
stdin as -, so a multi-line body needs no shell quoting:
printf -- '- [ ] verify checksum\n- [ ] sign the tarball\n' |
mdgrep "^## Release" --section-body --append-from -Files are written atomically, through a temporary file renamed over the
original. A checkbox that already reads the way you asked is reported unchanged
and left alone. -A, -B, -C, --lines, -c, -l and -m are refused
with an edit.
| Flag | Meaning |
|---|---|
-n, --line-number |
number the printed lines (the default) |
-N, --no-line-number |
drop the line-number gutter |
--no-breadcrumb |
hide the heading trail |
--color WHEN |
auto (default), always, never |
--json |
one JSON object per result |
-c, --count |
number of results per file |
-l, --files-with-matches |
names of matching files only |
-m, --max-count N |
stop after N results per file |
-q, --quiet |
print nothing; the exit status carries the answer |
--ext LIST |
file extensions to search |
--hidden |
descend into hidden directories |
--no-ignore |
search everything, including what the ignore files (.gitignore, .ignore, .git/info/exclude) and the skip list (node_modules, vendor, and friends) leave out |
-h, --help / -V, --version |
Colour turns itself off when stdout is not a terminal, or under NO_COLOR or
TERM=dumb.
--json emits one object per line: path, kind, score, start, end
(1-based, inclusive), breadcrumb, text, plus checked on task items. An
edit reports op, old, new and applied instead. A refused edit is one
object on stderr — error (ambiguous or expect), message, total,
expected and the capped matches list — so a JSON caller parses the refusal
with the reader it already has.
mdgrep "rollback" docs --json | jq -r '.path + ":" + (.start|tostring)'Exit status follows grep: 0 matched, 1 did not, 2 error. An error prints
the line that says what went wrong and points at --help.
main.go CLI: flags, file walking, worker pool
internal/mdoc goldmark AST → line-addressable block tree, sections, anchors
internal/match regexp / literal / fuzzy matchers and highlight spans
internal/search block selection, anchor lookup, expansion, merging
internal/edit planning and applying rewrites, atomic writes
internal/render terminal and JSON output
GFM is on, so tables, task lists, strikethrough and autolinks parse; front matter is one searchable node; every result is a verbatim slice of the file.
go test ./...Every layer that converts between byte offsets, line numbers and rune indices has a fuzz target, since that is where the three have to agree. Seeds run as ordinary tests; to actually fuzz one:
go test ./internal/mdoc -run xxx -fuzz FuzzParse -fuzztime 60s
go test ./internal/match -run xxx -fuzz FuzzMatch -fuzztime 60s
go test ./internal/edit -run xxx -fuzz FuzzEditPipeline -fuzztime 60s
go test ./internal/search -run xxx -fuzz FuzzSearchOptions -fuzztime 60s
go test . -run xxx -fuzz FuzzPermute -fuzztime 60sFailing inputs land in the package's testdata/fuzz as regression cases.