CSS Selectors for Code¶
We gave a code analysis tool the ability to query itself. The tool that parses 27 programming languages now parses its own query language — using its CSS grammar. Then we gave it declaration blocks, because “find this code” and “show me this code” are the same query with different back-ends.
The full selector reference — every pseudo-class, every combinator, every attribute operator — lives in the Sitting Duck docs. This post is the design story and the shape of the language, not the exhaustive reference.
The problem¶
You have source code. You want to ask structural questions about it. Not “find the string return” — grep does that. Questions like:
Functions that call
execute()but have notryblockMethods inside a specific class
Import statements that precede class definitions
Functions with returns at any nesting depth but no error handling
These questions share a property: they require knowing where things are relative to each other in the tree, not just whether they exist on a line. grep operates on lines. These questions operate on scopes.
What we built¶
Sitting Duck is a DuckDB extension that parses source code into ASTs using tree-sitter grammars. It already had ast_match (pattern-by-example with wildcards) and relational operators (ast_has, ast_inside, ast_precedes, ast_follows). These work, but they require knowing tree-sitter’s internal node type names.
We wanted something where you could just write what you mean. CSS selectors turned out to be almost exactly the right language:
-- Functions that call execute() but have no try block
SELECT name, start_line
FROM ast_select('src/**/*.py',
'.func:has(.call#execute):not(:has(.try))');
The syntax maps directly:
CSS |
AST meaning |
|---|---|
|
semantic type — cross-language (function_definition in Python, function_declaration in JS, func_declaration in Go, …) |
|
bare keyword — tree-sitter prefix match (function_definition, function_expression, …) |
|
exact tree-sitter type |
|
node named “main” |
|
attribute selector: name starts with |
|
B is a descendant of A |
|
B is a direct child of A |
|
B is the adjacent next sibling of A |
|
B is a later sibling of A |
|
contains X somewhere inside |
|
does NOT contain X |
|
navigate — return functions that call this node |
Three tiers of type specificity matter enough to name. .func is the broadest, cross-language alias — it matches across all 27 languages via the semantic layer. function is a language-specific tree-sitter prefix. function_definition is exactly that one tree-sitter node type. Pick the tier that matches the question you’re asking: cross-language audits use .func; debugging a single language uses bare keywords; forensics on one grammar uses exact types.
The bootstrap¶
Here’s the part that surprised us.
Sitting Duck already has a tree-sitter grammar for CSS — it’s one of the 27 supported languages. So to parse the CSS selector string, we just call our own parser:
-- Inside ast_select, the selector is parsed by:
SELECT * FROM parse_ast('.function:has(return_statement)', 'css');
This produces a structured AST of the selector itself:
pseudo_class_selector
class_selector
class_name: "function"
class_name: "has"
arguments
tag_name: "return_statement"
The macro walks this parsed selector AST to extract the type filter (.function), the pseudo-class (:has), and the argument (return_statement), then builds SQL conditions against the target code’s AST. No string parsing, no regex, no custom parser. The CSS grammar does the heavy lifting.
The selector parsing takes 1ms. The structural query takes the rest.
What it looks like¶
Find all methods in a JavaScript class:
SELECT name, start_line
FROM ast_select('src/*.js', 'class_body > method_definition');
constructor 8
login 13
generateToken 23
validateToken 27
Cross-language class discovery — one query, Python and JavaScript:
SELECT name, language, start_line
FROM ast_select('src/*.*', '.class');
AuthService javascript 7
Config python 10
DatabaseConnection python 25
UserService python 51
The compound selector that grep can’t express:
-- Functions with for-loops but no error handling
SELECT name, start_line, file_path
FROM ast_select('scripts/*.py',
'.function:has(for_statement):not(:has(try_statement))');
13 functions. Each one a candidate for adding try/except around the loop body.
Performance¶
We tested against Rosettacode — 834K AST nodes across 2,468 Python files:
Query |
Nodes |
Time |
Results |
|---|---|---|---|
|
834K |
4.1s |
6,317 |
|
834K |
4.4s |
4,833 |
Compound |
834K |
4.2s |
1,277 |
Descendant combinator |
834K |
4.5s |
7,282 |
The equivalent ast_has call takes 3.7s on the same dataset. The CSS selector overhead is ~20% at this scale — the fixed cost of parsing and dispatching the selector is noise against 834K nodes.
The cross-language query across Python + Java + Go (2.5M nodes, 5,946 files) runs in 13.5 seconds and returns 10,526 results. One selector, three languages, no language-specific patterns.
On smaller codebases (58K nodes), queries return in ~120ms. Interactive.
vs ripgrep¶
The obvious question: how does this compare to just using rg?
On the same 2,468 Python files:
Tool |
Query |
Time |
Result |
Correct? |
|---|---|---|---|---|
|
|
0.22s |
6,170 lines |
Lines, not functions |
|
multiline def…return (20-line window) |
0.25s |
2,332 |
Wrong — off by 2x |
|
|
4.4s |
4,833 |
Exact |
|
|
4.5s |
1,484 |
Exact — rg cannot express this |
rg is 20x faster but gives the wrong answer. Its multiline regex misses returns nested inside if/for/try/with blocks deeper than 20 lines, and has no concept of function boundaries — a return in the next function can false-match.
The negation query — “functions WITHOUT return” — rg cannot express at all. There is no regex for “a scope that does not contain a pattern.”
The trade-off: 0.25s for an approximate answer, 4.5s for the exact one. For the negation: 0.25s for nothing, 4.5s for 1,484 results.
The .semantic selector¶
The . (class) selector maps to semantic types — cross-language categories that abstract over language-specific node names:
Selector |
Matches |
|---|---|
|
|
|
|
|
|
|
|
|
all conditionals: |
|
all unconditional control transfers |
|
error handling constructs |
|
variable definitions |
|
module-level bindings |
The semantic layer is about ~80 aliases organized into kinds (.def, .flow, .error) and super-types. Both .func and .FUNC work — case-insensitive. Full table: Semantic Type Aliases.
The #name selector and [attr] operators¶
The # selector filters by the node’s extracted name, like CSS ID selectors. Attribute selectors extend this to substring, prefix, and suffix matches on names and other metadata:
-- The function named "main"
SELECT * FROM ast_select('src/*.py', '.func#main');
-- Everything that looks like a test: name starts with test_
SELECT name FROM ast_select('src/**/*.py', '.func[name^=test_]');
-- Handlers: name ends with _handler
SELECT name FROM ast_select('src/**/*.py', '.func[name$=_handler]');
-- Anything auth-related
SELECT name FROM ast_select('src/**/*.py', '.func[name*=auth]');
-- Zero-parameter functions (metadata, not just name)
SELECT name FROM ast_select('src/**/*.py', '.func[params=0]');
-- Comments containing TODO (source text match)
SELECT peek FROM ast_select('src/**/*.py', 'comment[peek*=TODO]');
-- Async functions
SELECT name FROM ast_select('src/**/*.js', '.func[modifier=async]');
Shorthand pseudo-classes exist for the common modifiers: :async, :static, :abstract, :const, :public, :private, :decorated, :typed, :void, :variadic. They compose with everything else.
Scope-aware queries: the pseudo-class that changed the shape of the answer¶
:has gets you most of the way there, but it can’t tell the difference between “this function calls execute” and “this function contains a nested function that calls execute.” Both nodes satisfy the descendant relation. For large codebases that distinction is the difference between a useful answer and a noisy one.
:scope(type) fixes it. The selector matches within the nearest ancestor of the given type, excluding subtrees of nested ancestors of the same type:
-- Return statements inside their DIRECT enclosing function
-- (skips returns that live in nested inner functions)
SELECT peek FROM ast_select('src/**/*.py', 'return_statement:scope(function)');
-- Calls inside the nearest class, not the one three levels down
SELECT name FROM ast_select('src/**/*.py', '.call:scope(class)');
The same machinery powers a set of call-graph pseudo-classes that are more ergonomic than writing the scope logic by hand:
-- Functions that call execute (direct scope only, no nested-function noise)
SELECT name FROM ast_select('src/**/*.py', '.func:calls(execute)');
-- All calls made by main()
SELECT name FROM ast_select('src/**/*.py', '.call:called-by(main)');
-- Unused functions: defined but never called
SELECT name FROM ast_select('src/**/*.py', '.func:not(:is-called)');
-- Dead exports: public API that nothing references
SELECT name FROM ast_select('src/**/*.py', ':exported:not(:is-referenced)');
These answers were possible before — you could JOIN ast_callees against ast_exports and get there — but they required knowing the schema. The pseudo-class makes the common question a one-liner. The uncommon question still has the macros underneath.
Declaration blocks: “find this” becomes “show me this”¶
The original selector language answered which nodes match? It returned rows. The follow-up question, asked over and over, was: okay, show me the code. Agents don’t want rows — they want source text with enough context to reason about.
So we added the other half of CSS. A selector can now be paired with a declaration block that controls how the match is rendered:
.fn#main -- bare selector: default rendering
.fn#main { show: body; } -- full function body
.class#Config { show: outline; } -- class signature + method signatures
.func[name^=test_] { show: signature; } -- just the def line for every test
.call#execute:not(:has(.try)) { show: enclosing; } -- unsafe call, rendered with its scope
The output is markdown code blocks with location headers, not AST rows:
# src/my_module.py:12-18
```python
def main(argv):
"""Entry point."""
config = parse_args(argv)
return run(config)
```
The show property picks which slice of the matched node appears:
Value |
Behavior |
|---|---|
|
The full matched node — default for functions, calls, statements. |
|
Declaration line(s) only — |
|
Signature plus child signatures with no bodies — default for classes and modules. |
|
Walks up to the nearest function/class and renders that with |
Defaults are type-specific: .fn/.call/.return default to body; .class/:root default to outline. The whole language parses as selector (declaration_block)?, so a bare selector is equivalent to { show: body }.
This is the layer that makes sitting duck usable as an AST viewer, not just a query engine. The same CSS string that filters nodes also tells the viewer how to present them. One grammar, two back-ends — a relational one that returns rows, and a rendering one that returns formatted source. And because declarations are parsed as forward-compatible key-value pairs, future additions (trace: callers; depth: 2, expand: .fn#__init__, show: call-site) can land without breaking existing queries.
For agents this matters more than it sounds. An agent that asks “show me the main function” wants the function — formatted, language-tagged, with a path header it can cite. Not a tuple of (start_line, end_line, node_id) that it has to re-query to read. The declaration block closes the loop.
Scope resolution and call graphs as macros¶
Underneath the pseudo-classes there’s a small family of macros that expose the raw scope and call-graph relations, for when you need a JOIN instead of a one-liner:
-- Module-level public API
SELECT name, type FROM ast_exports('src/**/*.py');
-- Reference → definition resolution via scope walk
SELECT ref_name, ref_line, def_line, def_type, scope_hops
FROM ast_resolve('src/main.py');
-- Call graph: every (caller, callee) pair
SELECT caller, callee, COUNT(*) AS n
FROM ast_callees('src/**/*.py')
GROUP BY caller, callee ORDER BY n DESC;
-- Cross-file call graph via import resolution
SELECT c.caller, c.callee, c.file_path AS caller_file, ex.file_path AS callee_file
FROM ast_callees('src/**/*.py') c
JOIN ast_exports('src/**/*.py') ex ON ex.name = c.callee
WHERE c.file_path != ex.file_path;
Every node in read_ast() output has a scope_id (the nearest enclosing scope boundary) and scope-creating nodes carry a scope_stack (the full chain from root). :scope(type), :calls(), :called-by(), ast_resolve, ast_callees, and ast_callers all build on those two columns. The CSS layer is the ergonomic front. The macros are the escape hatch.
What this required¶
Three changes to the sitting duck codebase:
ast_selectmacro (~300 lines of SQL) — parses the selector withparse_ast(selector, 'css'), extracts components by walking the selector AST, builds SQL conditions via a CASE dispatch. Single scan of the target AST, no UNION ALL.Relaxed wildcard child matching — while building this, we discovered that
ast_matchpatterns likeclass __C__ { __M__(__) { } }failed in JavaScript because theasynckeyword shifted child node positions. The fix: skip sibling-index verification for children of wildcard nodes. Now__M__matches methods regardless of modifiers.sibling_indexchanged from UINT32 to INT32 — DuckDB’s unsigned arithmetic madesibling_index - 1overflow whensibling_index = 0. A one-line type change that eliminated an entire class of bugs.
Why CSS¶
We considered tree-sitter’s native S-expression query language, a custom DSL, and CSS. CSS won because:
Familiarity — every web developer already knows it
The grammar already exists — tree-sitter-css ships with the extension
The concepts map naturally — descendant selectors, child selectors, sibling selectors,
:has(),:not()all have direct AST equivalentsWe could bootstrap — parsing the selector with the extension’s own CSS grammar is both elegant and zero-dependency
The semantics aren’t identical to CSS (.class maps to semantic types, not HTML classes; #name maps to identifiers, not HTML IDs), but the mental model transfers perfectly. If you can write div.active:has(> span.highlight), you can write function_definition:has(> return_statement).
Where it’s at now¶
The “planned for v1” list from the original version of this post has mostly shipped. :scope(type) is the most-used pseudo-class in the codebase. :precedes() / :follows() give you the reverse-direction sibling queries CSS can’t express. :first-child / :last-child / :nth-child(n) / :empty / :root work as you’d expect. NAME_ROLE flags are exposed as :definition / :reference / :declaration. The full pseudo-class vocabulary is over thirty filters across containment, positional, structural, scope, call-graph, ordering, modifier, and annotation categories.
A few newer additions worth calling out:
:matches("code")— structural substring match. Parses the argument as real code and checks whether that structure appears as a contiguous subtree within the matched node..func:matches("db.execute()")finds functions containing a specific structural pattern, not a regex. Uses___as a wildcard for “any name.” Cheap because DFS pre-order contiguity lets structural matching reduce to array substring matching. (The name and semantics of this pseudo-class are about to change — see the open design question below.)Call-graph pseudo-classes —
:calls(name),:called-by(name),:is-called,:is-referenced,:exported. Scope-aware by construction.Navigation pseudo-elements —
::callers,::callees,::parent-definition,::scope. Return different nodes rather than filtering.Attribute operators —
=,*=,^=,$=across[name],[type],[language],[semantic],[modifier],[annotation],[qualified],[signature],[params],[peek].Declaration blocks — the viewer layer described above. Ships alongside an AST CSS viewer that returns rendered source regions instead of rows.
The foundation held up. The CSS grammar does the parsing. The AST schema does the querying. The declaration block does the rendering. The extension still bootstraps itself.
Open design question: :matches drifted from CSS¶
Here’s a place where the current shipped behavior and the CSS mental model have quietly diverged, and we’re planning to fix it. Calling it out here because the rename is going to break existing queries, and we’d rather you see it coming.
In CSS, :matches() (and its modern alias :is()) matches the current element, not its descendants. div:matches(.active) means “this div is also .active.” It doesn’t mean “this div contains something that’s .active” — that’s what :has() is for.
Our current :matches("code") drifted. The implementation walks the matched node’s subtree and checks whether the argument’s parsed structure appears as a contiguous subtree anywhere inside. So function_definition:matches("db.execute()") finds any function whose body contains a db.execute() call anywhere. That’s useful — it’s the common case — but it’s really a compound of “has a descendant that matches the pattern,” which CSS would spell as :has(:matches(...)).
The proposal is:
Rename
:matches(...)→:match(...)and change its semantics to match the current node directly.call:match("db.execute()")succeeds only if the matched node is itself a call that structurally equals the pattern.Add
:contains("...")as syntactic sugar for:has(:match("..."))— the current behavior, spelled more honestly.Keep
:matchesas a deprecated alias for:containsfor one release so existing queries don’t silently break. After that, remove it.
This unlocks queries that don’t currently compose:
-- Comments that are NOT TODOs
SELECT peek FROM ast_select('src/**/*.py', 'comment:not(:match("TODO"))');
-- Calls that exactly match print(anything)
SELECT peek FROM ast_select('src/**/*.py', '.call:match("print(___)")');
-- Functions that contain db.execute() with no string literal argument
SELECT name FROM ast_select('src/**/*.py',
'.func:contains("db.execute(___)"):not(:contains("db.execute(\"___\")"))');
There’s also an implementation win. The current :matches iterates descendants within every matched subtree. :match is just a node-id equality check — no iteration, a much faster path for the common exact-node case. And the compound :has(:match(...)) can still use the optimized contiguous-subtree trick internally, so :contains doesn’t pay an extra cost.
One open sub-question: should :match require the target node type to equal the pattern’s root type, or should it be a prefix match? If the pattern is db.execute() (which parses as a call), does :match only succeed when the target is itself a call, or also when the target is an expression_statement wrapping that call? Current intuition is strict — target type must equal pattern root type — and users who want looser matching reach for :has(:match(...)) / :contains(...).
For readers who have queries using the current :matches("..."): the mechanical migration is to replace it with :contains("..."). Same semantics, new name. The deprecation alias will keep old queries working during the transition.
Why this matters beyond sitting duck¶
This post has been about one tool, but the move generalizes.
When we wrote The Specialization Lives in the Language, the argument was that small language models don’t need fine-tuning to hit a target reliably — they need a target language whose shape they already know. CSS is the archetypal example. Every code-trained model has seen millions of CSS selectors during training. The grammar is encoded in the weights. When you hand a 3B model a prompt that says “produce an AST query using this selector syntax,” the model isn’t learning a new DSL; it’s being cued to re-use a pattern that’s already there.
That’s what makes ast_select viable as a tool for LLM agents, not just a query language for humans. An agent reaches for CSS because its attention routes to CSS easily. The syntax it produces is syntactically valid on the first try because the weights already know :has() and :not() and [name^=test_]. The semantics get validated by the grammar and the schema, not by the model.
The declaration block extension is the same move applied twice. An agent that knows CSS also knows { property: value; } blocks. We didn’t invent a new framing language for rendering; we borrowed the one the model already understood. Both halves of the selector-plus-block string land in familiar territory.
This is dialect design as a design discipline: pick a grammar the model has seen, borrow its shape, and let the validator do the work the fine-tune would have done.
Sitting Duck is open source: github.com/teaguesterling/sitting_duck
Full selector reference and guides: sitting-duck.readthedocs.io — Node Types, Pseudo-Classes, Attributes, Semantic Aliases, Tutorial, Cookbook
Available as a DuckDB community extension: INSTALL sitting_duck FROM community; LOAD sitting_duck;