Four commands for understanding a complex function without reading it line by line
You get a bug report. The stack trace says processor.py:285. You open the file. The function is 180 lines.
You don't read 180 lines. You scan, you guess, you jump to what looks relevant, and you hope you didn't miss the branch that matters.
What you're actually doing — what every experienced developer does — is asking four questions:
- What's the structure? Where are the branches, loops, and exits?
- Where am I? What scope is this line actually in?
- What happened to this variable? Where was it set, and where was it used?
- What does this section call? What does it depend on?
These are navigation moves, not reading moves. And until recently, you had to perform them manually — scrolling, searching, holding structure in your head.
Reveal v0.72.0 makes each question a single command:
| Question | Command |
|---|---|
| What's the structure? | --outline |
| Where am I? | --scope :LINE |
| What happened to this variable? | --varflow VAR |
| What does this section call? | --calls START-END |
Together, they let you triangulate to exactly what you need without reading the full function body.
The four moves
The four flags work as a triangulation strategy — each answers a different navigation question:
graph TD
Start["You have a line number<br/>or a function to understand"]
Start --> Outline["--outline<br/>See the shape of the function"]
Outline --> Choice{Need more context?}
Choice -->|"locate yourself"| Scope["--scope :LINE<br/>What scope is this line in?"]
Choice -->|"follow data"| Varflow["--varflow VAR<br/>Where does this variable flow?"]
Choice -->|"trace behavior"| Calls["--calls START-END<br/>What does this section call?"]
Scope --> Done["Root cause<br/>identified"]
Varflow --> Done
Calls --> Done
style Start fill:#f1f5f9,stroke:#94a3b8
style Done fill:#dcfce7,stroke:#16a34a
style Outline fill:#e0f2fe,stroke:#0284c7
1. What's the structure? (--outline)
Before you read anything, see the shape:
reveal src/processor.py handle_request --outline
DEF handle_request L1→L180
FOR item in queue L12→L60
IF item.valid L15→L45
TRY L20→L44
EXCEPT ValidationError L38→L44
ELIF item.pending L47→L59
IF retry_count > MAX_RETRIES L62→L70
RETURN None L70
WHILE not result.complete L85→L140
IF result.status == 'error' L92→L105
RETURN result L178
One glance tells you: there's a FOR loop with validation, a retry gate, a WHILE loop that processes, and a return at the end. You now know where the branches are. You know where to look.
The default depth is 3. Go deeper with --depth 5 if the function is heavily nested.
2. Where am I? (--scope :LINE)
You have a line number from a stack trace or a grep result. What scope is it in?
reveal src/processor.py :92 --scope
DEF L1→L180 handle_request
WHILE L85→L140 while not result.complete
IF L92→L105 if result.status == 'error'
▶ L92: if result.status == 'error':
You're inside the IF block at L92, which is inside the WHILE loop, which is inside handle_request. That's context you'd normally build by scrolling up from line 92. Now it's one command.
3. What happened to this variable? (--varflow VAR)
Before you read any logic, you want to know: where does result come from, and where is it used?
reveal src/processor.py handle_request --varflow result
WRITE L23: result = None
WRITE L88: result = self._start_job(item)
READ/COND L92: if result.status == 'error':
READ L99: self.log(result.error)
READ/COND L112: if result.complete:
WRITE L118: result = self._retry(result)
READ L178: return result
Every assignment. Every conditional read. Every usage. In document order.
You can see immediately that result is initialized to None at L23, then assigned at L88, then read repeatedly — and reassigned at L118 if a retry happens. If something is wrong with result, the writes are at L23, L88, and L118. You didn't need to read the function to know that.
4. What does this section call? (--calls START-END)
You've identified a suspicious section — say, lines 88-105. What does it actually call?
reveal src/processor.py handle_request --calls 88-105
L88: self._start_job(item)
L92: result.status
L99: self.log(result.error)
L101: self.metrics.increment('errors')
L104: raise ProcessingError(result.error)
Every call site in that range, with callee name and line number. No reading required.
A real example: navigating a bug in Reveal itself
The best demonstration is a real one.
Reveal's _walk_var function — the function that implements --varflow — is 124 lines and composed entirely of inner closures: _walk_assignment, _walk_named_expression, _walk_for, _walk_with, _walk_if_while. Recently, a bug was reported: variable flow tracking was producing incorrect results for augmented assignments (x += 1).
Here's how we investigated it using the same tools.
First: get the skeleton.
reveal reveal/adapters/ast/nav.py _walk_var --outline
DEF _walk_var L364→L488
DEF walk(n: Any, c: str) -> None L379→L405
IF n.end_point[0] + 1 < from_line or line > to_line L385→L386
RETURN L386
IF ntype == 'identifier' and get_text(n) == var_name L388→L391
IF ntype in ('assignment', 'augmented_assignment') L393→L405
ELIF ntype == 'named_expression' L395→L396
ELIF ntype == 'for_statement' L397→L398
...
DEF _walk_assignment(n: Any, ntype: str, c: str) -> None L407→L425
IF left L411→L414
IF ntype == 'augmented_assignment' L412→L413
IF right L415→L416
FOR child in n.children L423→L425
IF (child.start_byte, child.end_byte) not in processed L424→L425
DEF _walk_for(n: Any, c: str) -> None L434→L451
...
The skeleton reveals immediately that the logic for augmented_assignment lives in _walk_assignment, specifically around L412. That's where to look.
Second: trace the processed set.
The processed set is used to avoid revisiting nodes. If it's broken, the whole traversal breaks.
reveal reveal/adapters/ast/nav.py _walk_var --varflow processed
WRITE L419: processed = {
READ/COND L424: if (child.start_byte, child.end_byte) not in processed:
WRITE L439: processed: set = set()
READ L441: processed.add((left.start_byte, left.end_byte))
READ L444: processed.add((right.start_byte, right.end_byte))
READ L447: processed.add((body.start_byte, body.end_byte))
READ/COND L450: if (child.start_byte, child.end_byte) not in processed:
WRITE L480: processed: set = set()
READ L482: processed.add((cond.start_byte, cond.end_byte))
READ/COND L485: if (child.start_byte, child.end_byte) not in processed:
Three separate WRITE sites. The processed set is being initialized three times, in three different inner functions. That means three separate sets — they don't share state.
Now look at the write at L419:
reveal reveal/adapters/ast/nav.py :419 --scope
DEF L364→L488 DEF _walk_var(
DEF L407→L425 DEF _walk_assignment(n: Any, ntype: str, c: str) -> None
▶ L419: processed = {
The processed set at L419 is initialized inside _walk_assignment. Its scope is that one closure call. When _walk_for runs (L434), it creates its own processed set at L439. The sets never talk to each other.
The original code had used id() for node identity — and tree-sitter returns new Python wrapper objects on each node access, so id() comparisons never matched. The fix: key on (start_byte, end_byte) instead.
We found the structure of the bug — three separate processed sets with broken identity — without reading 124 lines of code. The outline showed where the closures were. The varflow showed the three write sites. The scope confirmed where each one lived.
For AI agents
These flags matter more for agents than they do for humans.
When Claude Code encounters a 200-line function, the naive approach is to read all of it. That's ~7,500 tokens consumed before any reasoning happens. A large session might do this ten times — 75,000 tokens of raw file reading, most of it never referenced again.
The nav flags encode a different strategy:
# Step 1: structure (~80 tokens)
reveal src/handler.py process_event --outline
# Step 2: placement for the relevant line (~50 tokens)
reveal src/handler.py :285 --scope
# Step 3: the variable in question (~100 tokens)
reveal src/handler.py process_event --varflow payload
# Step 4: calls in the suspicious range (~60 tokens)
reveal src/handler.py process_event --calls 280-295
# Total: ~290 tokens
# vs. reading the function: ~7,500 tokens
That's not just a token saving — it's a different way of working. The agent isn't holding 200 lines in working memory and reasoning about all of it. It's asking targeted questions and getting targeted answers. The same way a senior developer would.
Token math
| Operation | Approximate tokens |
|---|---|
cat a 200-line function |
~7,500 |
--outline |
~80–200 |
--scope :LINE |
~50–100 |
--varflow VAR |
~60–200 |
--calls START-END |
~50–150 |
| All four nav flags | ~250–650 |
| Reduction | ~7–25x |
The savings compound. An agent investigating a bug across three functions might read 600 lines — 22,500 tokens — or run 12 nav commands — ~3,000 tokens. The investigation is faster, cheaper, and the results are more directly actionable.
Getting started
pip install reveal-cli
# Skeleton of a function
reveal src/your_module.py your_function --outline
# Where is this line?
reveal src/your_module.py :LINE --scope
# Variable trace
reveal src/your_module.py your_function --varflow variable_name
# What does a range call?
reveal src/your_module.py your_function --calls START-END
Class.method syntax works everywhere:
reveal src/your_module.py MyClass.your_method --outline
Available since v0.72.0. Full reference: reveal help://nav.
You don't understand code by reading it — you understand it by interrogating it. These four commands are the questions.
Part of the Reveal documentation series. See also:
- Find Every Caller in Your Codebase With One Command — cross-file call graph analysis
- Two Commands That Change How You Work With Code — pack and review
- Stop Reading Code. Start Understanding It. — the big picture
