Linux & Bash · Part II · Module 24 · Searching Design, Verification and Log Text
Searching a Mixed-Language Tree
A verification environment is rarely one language: SystemVerilog, Verilog, a C reference model and Python automation all describe the same design, and a signal name appears in all four. This chapter searches them together — and opens with a command from this module's own curriculum entry that finds nothing at all.
Intermediate20 min readLinuxPart IIgrepBrace ExpansionPortability
Part II · Module 24 · Chapter 24.3 · Searching a Mixed-Language Tree
chip_a has four languages describing one design:
$ git ls-files | sed 's|.*\.||' | sort | uniq -c | sort -rn
9 sv
6 f
2 md
1 txt
1 svh
1 sh
1 sdc
1 py
1 gitignore
1 cSystemVerilog for the design and testbench, a .svh header of defines, a C reference model, a Python regression driver, filelists and a shell script. reset_n appears in four of those, and a question like "is the reset polarity consistent everywhere" has to cross all of them.
1. The Command That Finds Nothing
This chapter's own curriculum entry suggests:
grep -rn --include='*.{sv,v,c,py}' 'reset_n' .It is wrong, and the way it is wrong is the most instructive thing in this chapter:
$ bash -c "/usr/bin/grep -rln --include='*.{sv,v,c,py}' reset_n . | wc -l"
0
$ bash -c "/usr/bin/grep -rn --include='*.{sv,v,c,py}' reset_n . >/dev/null 2>&1; echo rc=\$?"
rc=1Zero files, exit status 1. And exit 1 from grep means "no lines selected" — so this is indistinguishable from reset_n genuinely not appearing anywhere in the repository.
The cause is one layer below grep. Brace expansion is a shell feature, and quoting suppresses it:
$ bash -c 'printf " quoted: %s\n" --include='"'"'*.{sv,v}'"'"'; printf " unquoted: %s\n" --include=*.{sv,v}'
quoted: --include=*.{sv,v}
unquoted: --include=*.sv
unquoted: --include=*.vOne argument when quoted, two when not. So grep receives a single --include whose glob is the literal string *.{sv,v,c,py}, and no file is named that.
Chapter 9.3 is the mechanism — quotes stop the shell from processing a word — and the usual lesson is "quote your globs so the tool gets the pattern". This is the case where that lesson is exactly backwards, because the braces have to be processed by the shell and only the * has to survive to grep.
2. Three Forms That Work
Unquoted braces, in bash:
$ bash -c "/usr/bin/grep -rln --include=*.{sv,v,c,py} reset_n . | wc -l"
4Repeated --include, the portable one:
$ /usr/bin/grep -rln --include='*.sv' --include='*.v' --include='*.c' --include='*.py' reset_n . | sort
./c_model/ref_model.c
./rtl/axi/axi_slave.sv
./scripts/regress.py
./tb/agents/axi_mon.svFour files, four languages — which is the answer the question wanted.
Or an array, when this is in a script:
EXT=(--include='*.sv' --include='*.svh' --include='*.v' --include='*.vh'
--include='*.c' --include='*.h' --include='*.py')
/usr/bin/grep -rln "${EXT[@]}" reset_n .Each element reaches grep as exactly one argument with its glob intact — Chapter 19.5, and the form every script in this module uses.
3. Narrowing by Language and Directory Together
The two axes are independent, and most real questions constrain both:
| Question | Extensions | Directories |
|---|---|---|
| "Where is this declared in the design?" | *.sv *.svh *.v | rtl |
| "Does the C model use the same polarity?" | *.c *.h | c_model |
| "Which tests set this?" | *.sv *.svh | tb/tests |
| "Is it in the build flags?" | *.sh *.py *.f *.mk | scripts filelists |
| "Everywhere, for a rename" | all of them | rtl tb c_model scripts |
Measured per language:
$ for e in sv svh c py; do
> printf ' *.%-4s %s file(s)\n' "$e" "$(/usr/bin/grep -rl reset_n --include="*.$e" . | wc -l | tr -d ' ')"
> done
*.sv 2 file(s)
*.svh 0 file(s)
*.c 1 file(s)
*.py 1 file(s)*.svh is zero, which is itself a finding: reset_n is not in the defines header, so the name is not configurable — a rename has to touch every file individually. That is the kind of answer a per-language breakdown gives you and a flat list does not.
4. The Same Concept, Two Spellings
Crossing languages means the name changes even when the concept does not — and in chip_a it changes inside one language. Which files say which:
$ /usr/bin/grep -rl --include='*.sv' 'rst_n' .
./tb/tests/tb_chip.sv
./run_2026_10_01/axi_master.sv
./rtl/core/chip_top.sv
./rtl/axi/axi_master.sv
./rtl/uart/uart_tx.sv
./ip/vendor_axi/rtl/axi_master.sv
$ /usr/bin/grep -rl --include='*.sv' 'reset_n' .
./tb/agents/axi_mon.sv
./rtl/axi/axi_slave.svSix files say rst_n and two say reset_n, for the same active-low reset. The C model uses reset_n; the Python driver spells the related constant RESET_N_SETTLE. Searching for either name alone finds a fraction of the tree, and nothing warns you that the rest exists.
The alternation is the fix, and it needs -E:
$ /usr/bin/grep -rn --include='*.sv' --include='*.c' --include='*.py' -E 'reset_n|rst_n|RESET' . | sed 's|^\./||'
tb/tests/tb_chip.sv:2: logic clk = 0, rst_n = 0;
tb/tests/tb_chip.sv:4: chip_top dut (.clk(clk), .rst_n(rst_n), .status(status));
tb/tests/tb_chip.sv:8: @(negedge clk); rst_n = 1;
tb/agents/axi_mon.sv:2:module axi_mon (input logic clk, input logic reset_n, input logic awvalid);
tb/agents/axi_mon.sv:5: .reset_n (reset_n),
c_model/ref_model.c:2:/* reference model: reset_n is active low, matching the RTL */
c_model/ref_model.c:3:int reset_n = 0;
c_model/ref_model.c:5: if (!reset_n) return 0; /* awvalid ignored during reset */
run_2026_10_01/axi_master.sv:2:module axi_master (input logic clk, input logic rst_n, output logic [7:0] stat);
scripts/regress.py:3:RESET_N_SETTLE = 10 # cycles to hold reset_n low
rtl/core/chip_top.sv:2:module chip_top (input logic clk, input logic rst_n, output logic [7:0] status);
rtl/core/chip_top.sv:4: axi_master u_axi (.clk(clk), .rst_n(rst_n), .stat(s_axi));
rtl/core/chip_top.sv:5: uart_tx u_uart (.clk(clk), .rst_n(rst_n), .stat(s_uart));
rtl/axi/axi_slave.sv:3: (input logic clk, input logic reset_n,
rtl/axi/axi_slave.sv:8: if (!reset_n) awready <= 1'b0;
rtl/axi/axi_master.sv:2:module axi_master (input logic clk, input logic rst_n, output logic [7:0] stat);
rtl/axi/axi_master.sv:3: always_ff @(posedge clk) if (!rst_n) stat <= 8'h00; else stat <= 8'hA1;
rtl/uart/uart_tx.sv:1:module uart_tx (input logic clk, input logic rst_n, output logic [7:0] stat);
rtl/uart/uart_tx.sv:2: always_ff @(posedge clk) if (!rst_n) stat <= 8'h00; else stat <= 8'h5C;
ip/vendor_axi/rtl/axi_master.sv:2:module axi_master (input logic clk, input logic rst_n, output logic [7:0] stat);Twenty lines across four languages — and note that two of them are in directories you did not want: run_2026_10_01/ and ip/vendor_axi/, which is Chapter 23.5 arriving again because this search used . as its scope.
Three things about writing the alternation:
Longest alternative first. grep only reports whether a line matched, so the order is invisible here — but the moment you switch to -o to extract the matched text, rst would match inside rst_n and print the shorter form. Ordering branches longest-first is a habit that costs nothing and prevents that.
A trailing word boundary, or rst matches rstate. The portable spelling is [^a-zA-Z0-9_], and it has to allow end-of-line: (rst_n|reset_n)([^a-zA-Z0-9_]|$).
And no leading boundary, on purpose, because i_rst_n and s_axi_reset_n are the same concept with a bus prefix. Whether you want those is the real decision; make it deliberately rather than by accident. In this tree it does not matter — there are no prefixed forms — which is exactly the kind of thing to check rather than assume.
A rename is where this bites hardest: a search that misses one spelling produces a half-renamed design that still compiles, because both names are valid identifiers and the compiler has no opinion about which one you meant.
5. The Script
#!/usr/bin/env bash
# xsearch — search a mixed-language tree by concept, with a per-language breakdown
#
# usage: xsearch [-l lang]... [-d dir]... <ere>
# -l sv | c | py | sh | all (repeatable; default: all)
# -d directory (repeatable; default: the source dirs that exist)
# exit: 0 matches · 1 none · 2 bad usage
set -o nounset
GREP=${GREP:-/usr/bin/grep}
ext_sv=(--include='*.sv' --include='*.svh' --include='*.v' --include='*.vh')
ext_c=(--include='*.c' --include='*.h' --include='*.cpp' --include='*.hpp')
ext_py=(--include='*.py')
ext_sh=(--include='*.sh' --include='*.bash' --include='*.f' --include='*.mk' --include='Makefile*')
langs=(); dirs=()
while getopts ':l:d:' o; do
case $o in
l) langs+=("$OPTARG") ;;
d) dirs+=("$OPTARG") ;;
:) printf 'xsearch: -%s needs a value\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'xsearch: unknown option -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
[ $# -eq 1 ] || { printf 'usage: xsearch [-l lang]... [-d dir]... <ere>\n' >&2; exit 2; }
pat=$1
[ "${#langs[@]}" -gt 0 ] || langs=(all)
if [ "${#dirs[@]}" -eq 0 ]; then
for d in rtl tb src hdl verif c_model model scripts; do [ -d "$d" ] && dirs+=("$d"); done
fi
[ "${#dirs[@]}" -gt 0 ] || { printf 'xsearch: no source directories; use -d\n' >&2; exit 2; }
inc=()
for l in "${langs[@]}"; do
case $l in
sv) inc+=("${ext_sv[@]}") ;;
c) inc+=("${ext_c[@]}") ;;
py) inc+=("${ext_py[@]}") ;;
sh) inc+=("${ext_sh[@]}") ;;
all) inc+=("${ext_sv[@]}" "${ext_c[@]}" "${ext_py[@]}" "${ext_sh[@]}") ;;
*) printf 'xsearch: unknown language: %s\n' "$l" >&2; exit 2 ;;
esac
done
out=$("$GREP" -rnE "${inc[@]}" -- "$pat" "${dirs[@]}" 2>/dev/null) || {
printf 'xsearch: no match for %s in %s\n' "$pat" "${dirs[*]}" >&2; exit 1; }
printf '%s\n' "$out" | sed 's|^\./||'
printf '\n-- by extension\n'
printf '%s\n' "$out" | sed 's/:.*//; s|.*\.||' | sort | uniq -c | sort -rn | sed 's/^/ /'
printf -- '-- by directory\n'
printf '%s\n' "$out" | sed 's/:.*//; s|^\./||; s|/.*||' | sort | uniq -c | sort -rn | sed 's/^/ /'$ ./xsearch '(reset_n|rst_n)[^a-zA-Z0-9_]'
rtl/core/chip_top.sv:2:module chip_top (input logic clk, input logic rst_n, output logic [7:0] status);
rtl/core/chip_top.sv:4: axi_master u_axi (.clk(clk), .rst_n(rst_n), .stat(s_axi));
rtl/core/chip_top.sv:5: uart_tx u_uart (.clk(clk), .rst_n(rst_n), .stat(s_uart));
rtl/axi/axi_slave.sv:3: (input logic clk, input logic reset_n,
rtl/axi/axi_slave.sv:8: if (!reset_n) awready <= 1'b0;
rtl/axi/axi_master.sv:2:module axi_master (input logic clk, input logic rst_n, output logic [7:0] stat);
rtl/axi/axi_master.sv:3: always_ff @(posedge clk) if (!rst_n) stat <= 8'h00; else stat <= 8'hA1;
rtl/uart/uart_tx.sv:1:module uart_tx (input logic clk, input logic rst_n, output logic [7:0] stat);
rtl/uart/uart_tx.sv:2: always_ff @(posedge clk) if (!rst_n) stat <= 8'h00; else stat <= 8'h5C;
tb/tests/tb_chip.sv:2: logic clk = 0, rst_n = 0;
tb/tests/tb_chip.sv:4: chip_top dut (.clk(clk), .rst_n(rst_n), .status(status));
tb/tests/tb_chip.sv:8: @(negedge clk); rst_n = 1;
tb/agents/axi_mon.sv:2:module axi_mon (input logic clk, input logic reset_n, input logic awvalid);
tb/agents/axi_mon.sv:5: .reset_n (reset_n),
c_model/ref_model.c:2:/* reference model: reset_n is active low, matching the RTL */
c_model/ref_model.c:3:int reset_n = 0;
c_model/ref_model.c:5: if (!reset_n) return 0; /* awvalid ignored during reset */
scripts/regress.py:3:RESET_N_SETTLE = 10 # cycles to hold reset_n low
-- by extension
14 sv
3 c
1 py
-- by directory
9 rtl
5 tb
3 c_model
1 scriptsEighteen lines, and none of them from run_2026_10_01/ or ip/ — because the discovered root list names source directories rather than starting at .. That is Chapter 23.5's positive-list argument built into the default.
The error paths, measured:
$ ./xsearch; echo "rc=$?"
usage: xsearch [-l lang]... [-d dir]... <ere>
rc=2
$ ./xsearch -l bogus 'x'; echo "rc=$?"
xsearch: unknown language: bogus
rc=2
$ ./xsearch 'zzz_no_such' > /dev/null; echo "rc=$?"
xsearch: no match for zzz_no_such in rtl tb c_model scripts
rc=1Six decisions:
Per-language option groups as arrays, never strings, for §2's reason.
-l and -d both repeatable, because a question constrains both axes and the natural form is -l sv -l c -d rtl -d c_model. Chapter 21.2 §6 is the repeatable-option idiom.
all as a named language rather than the absence of -l, so the default is visible in the usage text. A default you cannot name is a default nobody knows about.
The breakdowns are computed from the captured output, not by re-running grep per language. One traversal, several summaries — and it means the breakdown cannot disagree with the listing above it, which a second traversal could.
sed 's/:.*//' to recover the file name from grep's file:line:text, which works because a path in this tree contains no colon. That assumption is worth stating: a path can contain a colon, and then this miscounts. grep -l in a second pass is the robust alternative, at the cost of a second traversal.
And -- "$pat", so a pattern beginning with a dash is a pattern.
Practice
- Run the registry's quoted-brace command on your own tree. How many files? What is
$? - Run the unquoted version in bash and in zsh. Record both behaviours.
- Explain in one sentence why the more carefully quoted form is the broken one.
- Write out the repeated-
--includeform for five extensions. Then put it in an array and confirm the results are identical. - Run
dash -cwith the unquoted braces and confirm it does not expand them. - Count your tree's files by extension. Which languages did you not expect?
- Pick a signal and count its hits per language. Is any language zero, and what does that tell you?
- Search everything and filter with
grep -E '\.(sv|c):'. Then construct a content line that the filter matches falsely. - Find a concept in your repository that has two spellings. How many files does each find? (Measured here: six and two.)
- Write the alternation that finds both, with a trailing word boundary, and check whether prefixed forms like
i_rst_nshould be included. - Write
xsearchand use-land-dtogether to answer a real question. - Give
xsearchan unknown language and confirm exit 2.
Check Your Understanding
- Why does
--include='*.{sv,v,c,py}'find nothing? - Why is exit status 1 the dangerous part of that failure?
- What does the unquoted form do in bash, zsh and dash respectively?
- Which two forms behave identically in all three shells?
- Why is "quote your globs" exactly backwards here?
- Give three reasons to narrow a search rather than filter its output.
- What false positive can a
grep -E '\.sv:'post-filter produce? - What does a zero count for one extension tell you?
- Why put the longest alternative first in an alternation?
- In
xsearch, why are the breakdowns computed from the captured output?
6. Remember
--include='*.{sv,v,c,py}'finds nothing and exits 1. Quoting suppresses brace expansion.- Exit 1 means "no lines selected", so the broken form looks exactly like a genuine negative.
- Unquoted braces: bash expands correctly, zsh errors and does not run, dash does not expand at all.
- Repeated
--includeor an array are the only forms identical in all three shells. - Here, "quote your globs" is backwards — the shell must see the braces.
- Narrow at the source, not at the sink:
--includeplus a directory list, never a post-filter ongrep's output. - A post-filter on
\.sv:can match a content line, which you cannot see. - Break the count down per language. A zero is a finding.
- One concept, two spellings — measured: six files say
rst_n, two sayreset_n. Search the alternation, longest branch first, with a trailing word boundary. - A half-renamed design still compiles, because both names are valid identifiers.
- Pass option groups as arrays, make
-land-drepeatable, and name the default. - Compute summaries from one captured traversal, so they cannot disagree.
7. What Is Next
Everything so far has searched text a person wrote. Chapter 24.4 turns to text a tool wrote, which needs the opposite strategy — and opens with a measured case where counting a severity the obvious way reports an error that never happened.
Where this fits
Part of the Linux & Bash curriculum.
