rbtr language plugins
How rbtr turns source into chunks, and the conventions for changing it.
Pairs with the rbtr-testing skill (the sample/snapshot harness) and the
rbtr-languages tags reference in references/tags-scm-reference.md.
Comprehensiveness principle
rbtr aims to index everything an engineer might search for or navigate to.
If a developer could plausibly grep a name, or want "jump to definition" on
it, it should be a chunk. Bias toward over-capture, not under-capture.
- "Don't capture X" must be justified by "X is not a definition / has no
searchable identity" — never by "X is rare/niche". Rarity is not a reason
to skip.
- Definition-shaped constructs are in scope even when they need bespoke work:
C function prototypes (the API surface in headers),
namespace/mod
as their own symbols, Bash aliases (a written command definition, like
source), SCSS @mixin/@function/$variables. Capture them.
- Written constructs only — the written code is the unit, not its runtime
effects. rbtr extracts what is in the source, not what a macro/
metaprogramming call generates. Ruby
attr_accessor :name is written and
findable as content (it sits in the class chunk), so full-text search
already locates it — but do not synthesise name/name= method chunks
for the accessors it generates at runtime; those are not written code.
(Capturing the written :name token as a field is a possible future
nicety, not a default.)
- Comments are content, not decoration. Top-level comments — banners,
licence headers, section notes — and module docstrings are captured as
COMMENT chunks so their text is searchable, not dropped; a comment above
a definition folds into it as documentation. See Comment handling.
- Legitimate skips: truly anonymous nodes with no name a human would search
(a
tree_sitter_query pattern with no outer label — captured only as an
anonymous section), pure metadata (COMMENT ON), usage/references (which
belong to edges, not symbol chunks), and runtime-generated symbols
(per above).
- When unsure, capture it. A spurious chunk is cheaper than an invisible
symbol.
Contained definitions & the data-scope exception
A definition that lives inside a larger definition we already chunk is
represented by that parent chunk — it is full-text-findable there and does
not get its own chunk. Statements inside a function, and data members of a
class (class attributes, enum members, dataclass fields), are represented by
the enclosing function/class chunk. Methods are the exception: substantial,
separately-navigable units, so they do get their own chunk.
The data-scope exception (language philosophy). Variable capture targets
the language's idiomatic top-level data scope, not nested members — and
what counts as "top-level" differs by language:
- Languages with a module / file / package data scope (Python module,
Go/Rust/C file or package, JS/TS module, Bash script) capture variables
there; class/struct data members stay contained in their parent chunk.
- Class-only languages with no top-level data scope (Java): the class is the
only namespace, so fields are the top-level data definitions and are
captured (scoped to their class). Not capturing them would leave Java with
zero variable chunks.
This is not an inconsistency between languages — it is the same rule
(capture top-level data at the language's idiomatic scope) applied to
differing language philosophies. It keeps capture comprehensive without
exploding every class into per-field chunks, while never leaving a language's
data definitions invisible.
Architecture
A plugin is a package (rbtr-lang-<lang>) exposing one or more
module-level LanguageRegistration values — each named by its language id
— through the rbtr.languages entry-point group; the LanguageManager
discovers them via importlib.metadata (no pluggy — languages are
single-dispatch by id). Core's bundled languages register the same way,
from core's own pyproject. build_index routes extraction down one of
three paths:
- Chunker — a chunker attached via
@reg.chunker: prose (markdown,
rst) and SFCs' markup
(svelte, vue). The chunker owns extraction. It takes an optional ranges
and must set parser.included_ranges when given it, so it can also serve
as an injection target for an embedded block (see below).
- Query —
reg.grammar_module + a QueryExtraction: code, plus config/data
whose scope a query can express (python, rust, …, json, css, html, toml,
yaml, hcl, tree_sitter_query). Goes through extract_symbols.
HTML captures its semantic elements (head, body, sectioning content,
landmarks) as doc sections, named by id else tag via a name_extractor.
The tree_sitter_query plugin indexes .scm files themselves: each
top-level pattern is a @doc_section, named by its own outer capture else
anonymous.
- Plaintext fallback — no grammar/detection: fixed-size raw chunks.
extract_symbols is the query engine: parse → run query → captures →
Chunks. It takes the LanguageRegistration and delegates naming,
scoping, and imports to it (reg.resolve_name / resolve_scope /
resolve_import), each calling the language's resolver — the built-in, or an
override composed over it.
Injection (embedded languages) is an orthogonal capability that runs in
addition to the primary path. To extract code embedded in a host file (an
SFC's <script>/<style>, a Markdown fenced block, an HTML inline
<script>/<style>), set reg.injection_query: a tree-sitter query over the
host grammar that captures each embedded block as @injection.content and
names its target language one of two ways:
- Static —
(#set! injection.language "<id>"), for a closed set (SFC/HTML
<script>→js/ts, <style>→css), with an optional
(#set! injection.priority "<n>") so a lang-tagged rule beats a bare one.
- Dynamic — capture the language name as
@injection.language (a Markdown
fence's info string). The engine resolves the captured text via the
registry's own id and extension maps (python/py both reach python); an
unknown hint is left unparsed. No per-language mapping table.
The engine delegates each block's range to the target's full primary
extraction (extract_primary — chunker or query, so a chunker target like
yaml/toml works, not just query targets) and recurses into the target's own
injection (an HTML block containing an inline <script> yields its js), all
at absolute line numbers. Every file also gets a host-language chunk (a
content-less presence chunk if it would produce none), so dedup works. See
ARCHITECTURE “Dispatch chain” for the mechanism and rationale.
Where queries live
Every query — reg.extraction.query, reg.injection_query, and any query a chunker
compiles — is a .scm file co-located in the plugin package
(rbtr_lang_<lang>/<name>.scm), loaded at import via load_query (import
it: from rbtr.languages.registration import load_query; call it:
load_query(__package__, "<name>")). Call it directly in the extraction or
injection_query field — never hoist it into a module-level _QUERY constant;
load_query is cached on (package, name), so a query shared by two
registrations (svelte and vue's SFC injection query) is read once. Never inline
a query as a Python
string literal (the house rule against embedding a foreign language). The
uv build backend ships .scm as package data with no extra config.
Compose in Python when a query is built from parts — the query language has
no #include: js/ts concatenate shared fragment files with +
(load_query(pkg, "javascript") + load_query(pkg, "shared") + …); SQL groups
its DDL verbs into [...] alternations within one sql.scm. Prefer these to
generating query text from Python data. Editing a .scm is an extraction
change — the extraction_serial bump rule (below) applies.
Capture conventions
The query's capture names drive the chunk kind (see _CAPTURE_KINDS in
languages/treesitter.py):
@function / @_fn_name — functions
@class / @_cls_name — classes, structs, enums, traits, types, and
named collections of declarations (CSS rule sets, @media, @keyframes)
@method / @_method_name — methods (a @function whose nearest scope is
class-like is also promoted to a method)
@variable / @_var_name — module/top-level variables, constants, fields
@import — import statements (metadata via import_extractor)
@_scope — optional: a node whose text becomes the symbol's innermost
scope segment, for scopes lexical nesting can't reach (e.g. a Go method's
receiver type). Strictly additive: absent → no effect.
@_docstring — interior first-statement docstring (Python)
@doc_section / @_section_name — chunker/data section units
@config_key / @_section_name — config/data keys (JSON object keys, TOML
tables, YAML mapping keys, HCL blocks, CSS @charset), reusing the
@_section_name name capture
@comment — top-level comments and module docstrings; grouped into blocks
and either folded into the definition below or emitted as standalone
COMMENT chunks (see Comment handling)
Capture names starting with _ are read but never become chunks.
The display name comes from the paired @_*_name capture via the built-in
name resolver. When a query cannot express the name, attach a
name_extractor — @reg.name_extractor for a single-use local override,
or reg.name_extractor(fn) for a shared/imported one — as a last
resort. It is wrap-style (pydantic WrapValidator shape): signature
(resolver, capture_name, node, captures) -> str, where resolver is the
built-in resolver handed in; call it to delegate the cases you don't
special-case, exactly as an import_extractor receives the built-in import
resolver. Bash strips the = the grammar fuses onto an alias; HTML names an
element by its id, else its tag.
The scope address comes from tree ancestry (scope_types, below) plus the
scope_extractor — the scope twin of name_extractor, whose built-in
resolver contributes the @_scope capture. Attach a custom
one the same way (wrap-style signature
(resolver, capture_name, node, captures) -> list[str], outermost-first)
as a last resort, for a hierarchy neither ancestry nor @_scope can
reach; its segments are appended to the ancestry scope. Two real cases:
CSS/SCSS/Less nested rules — walk the ancestor rule_set selectors so
.card { .title { … } } scopes .title under .card:
def css_nesting_scope(_resolver, capture_name, node, captures):
segments: list[str] = []
for rule_set in enclosing_nodes_of_type(node, frozenset({"rule_set"})):
for child in rule_set.children:
if child.type == "selectors" and child.text:
segments.append(child.text.decode().strip())
break
return segments
TOML dotted tables — the hierarchy is a dotted-key string, not tree
ancestry, so a name_extractor returns the last segment and a
scope_extractor the preceding ones ([tool.ruff] → name ruff, scope
tool).
Comment handling
Comments are captured, not configured. Give your grammar's comment node(s) a
root-scoped @comment capture so only top-level comments match — a comment
inside a function body stays part of that body's chunk:
- most grammars:
(translation_unit (comment) @comment), (program (comment) @comment), (source_file (comment) @comment), (stylesheet (comment) @comment) — use the grammar's actual root and comment node type;
- multiple comment node types go in an alternation: Rust/Java
[(line_comment) (block_comment)], SQL [(comment) (marginalia)], SCSS/Less [(comment) (js_comment)];
- Python also captures its module docstring:
(module (expression_statement (string) @comment)).
The engine does the rest, identically for every language: it groups top-level
comment runs into blank-line-delimited blocks, folds a block flush above a
definition into that definition, leaves interior comments in their body, and
emits everything else as a standalone COMMENT chunk. You only declare the
node types. A comment trailing code on its line documents that statement and
never folds forward. The routing rules and their rationale live in ARCHITECTURE.
Scope & promotion (engine layers — already generic)
Set on the language's QueryExtraction (the extraction field);
extract_symbols applies them to every captured node:
scope_types — node types that open a naming scope; composed into the
:: address. Include nesting containers (classes, namespaces, modules,
functions where nested defs matter).
class_scope_types — the subset that is class-like; a function directly
inside one is promoted to a method. Defaults to scope_types.
- Non-lexical scope comes from
@_scope (above) or, when even that can't
reach it, a scope_extractor (above), not these.
Authoring or extending a plugin
- Read the grammar: its
queries/tags.scm (the authors' definition list —
see the tags reference) and the real node structure (parse a snippet,
print the tree). Never guess node types.
- Edit the language's
.scm query file (or chunker) — queries live in
the plugin package (rbtr_lang_<lang>/*.scm), loaded via load_query,
never inline (see Where queries live). Verify against a parsed
snippet.
- Add the construct to that language's sample in the package's
tests/samples/ and regenerate the snapshot with just snapshots;
review the diff.
- Bump
extraction_serial — any extraction change triggers
re-extraction of stored blobs. It is independent of the package
version: bump it during development (before any release) when output
changes, and never bump it for a package-only release or pure move.
just check. (Samples are exempt from lint/type/format — see below.)
Gotchas (all learned the hard way; verified)
- Require a body on type captures.
(struct_specifier name: …) matches
references too (e.g. inside typedef struct G G;, or a parameter type),
producing spurious class chunks. Require body: so only definitions match
(C/C++ struct/enum/class).
- Take a span's last line from
last_line. Tree-sitter rows are
0-based, so end_point[0] + 1 is right for a node ending mid-line and
one too many for a node that consumes its trailing newline and ends at
column 0 of the next row. Ten sites once computed this three ways: a
toml table claimed the next table's header line, a rust comment block
lost its last line, and a heading-less rst paragraph ran backwards
(line 7 to line 6). Import last_line from rbtr.languages.chunks
and delete any local trailing-newline correction — a compensation left
beside it decrements twice.
- Non-lexical scope →
@_scope. Scope is otherwise lexical-ancestry only
(_enclosing_scopes walks parents). A Go method's receiver is a child, so
capture it as @_scope.
- Determinism in test helpers.
next(iter(reg.extensions)) over a
frozenset varies with PYTHONHASHSEED; the chunk id hashes file_path,
so derive paths deterministically (sorted(...), or pass an explicit path).
- Imports are always bespoke.
tags.scm has no imports; the edge system
depends on them. Keep per-language import_extractors.
- SQL / multi-dialect: don't gate on parse-clean. One generic SQL grammar
serves all
.sql. Tree-sitter error recovery is local — a dialect
construct it can't parse breaks only its own subtree; surrounding
statements still extract. Treat has_error as informational, not a gate.
- Known-unsupported constructs → strict xfail. Record a construct that
should extract but can't (grammar/plugin limit) as an
xfail(strict=True)
case, so closing the gap flips the test and prompts an update. Reserve for
symbol-shaped gaps (kind, name, scope); import-identity gaps don't fit.
tags.scm: a reference, not a runtime source
Every code grammar ships queries/tags.scm — the authors' standard
definition/reference query (@definition.* / @name / @reference.*). It is
itself a tree-sitter query, so it is inspiration for ours, not a drop-in:
- Mine it: take good patterns, modify weak ones, ignore wrong ones.
Verify every pattern against a real parse — quality is uneven (e.g. C's
union pattern misses standalone union U {}).
- It captures no imports and few variables, omits some constructs
(TS
enum/type alias, Java enum/record), some grammars inherit others
(ts ← js), and only the 9 code grammars ship one.
- Running it live would couple extraction to upstream drift. We don't.
Distinct from rbtr's own .scm files (see Where queries live): we load
those at runtime (reg.extraction.query / reg.injection_query) as the
source of truth, whereas tags.scm we only mine for ideas. And the
tree_sitter_query plugin now indexes .scm files found in a repo —
including third-party tags.scm / highlights.scm / injections.scm — as
content, orthogonal to whether we
run them.
Curated per-language verdicts (take / modify / ignore) and the
@definition.* → ChunkKind mapping live in
references/tags-scm-reference.md.
Testing
Each language has a sample mini-project under its package's tests/samples/
(one or more files), golden-snapshotted (full model_dump_json),
coverage-checked, and parse-clean-checked. See rbtr-testing. Samples are
exempt from the repo's linters/type-checker (they're fixtures) — validated
only by their own tests.
1---2name: rbtr-languages3description: Conventions for authoring and extending rbtr language plugins — the tree-sitter queries, chunkers, capture conventions, and extraction-engine layers. Use when writing or modifying a language plugin (an `rbtr-lang-*` package or core's bundled `rbtr/languages/`), editing a `.scm` query, a `LanguageRegistration`, an `import_extractor`, or a chunker; when adding support for a new construct or language; or when reviewing extraction behaviour. Also trigger on tree-sitter queries, `extract_symbols`, `tags.scm`, or `@definition`/`@function`/`@_scope` captures.4---56# rbtr language plugins78How rbtr turns source into chunks, and the conventions for changing it.9Pairs with the **rbtr-testing** skill (the sample/snapshot harness) and the10`rbtr-languages` tags reference in `references/tags-scm-reference.md`.1112## Comprehensiveness principle1314**rbtr aims to index everything an engineer might search for or navigate to.**15If a developer could plausibly grep a name, or want "jump to definition" on16it, it should be a chunk. Bias toward **over-capture**, not under-capture.1718- "Don't capture X" must be justified by **"X is not a definition / has no19 searchable identity"** — never by "X is rare/niche". Rarity is not a reason20 to skip.21- Definition-shaped constructs are in scope even when they need bespoke work:22 C function **prototypes** (the API surface in headers), `namespace`/`mod`23 as their own symbols, Bash `alias`es (a written command definition, like24 `source`), SCSS `@mixin`/`@function`/`$variables`. Capture them.25- **Written constructs only — the written code is the unit, not its runtime26 effects.** rbtr extracts what is in the source, not what a macro/27 metaprogramming call *generates*. Ruby `attr_accessor :name` is written and28 findable as content (it sits in the class chunk), so full-text search29 already locates it — but do **not** synthesise `name`/`name=` method chunks30 for the accessors it generates at runtime; those are not written code.31 (Capturing the written `:name` token as a field is a possible future32 nicety, not a default.)33- **Comments are content, not decoration.** Top-level comments — banners,34 licence headers, section notes — and module docstrings are captured as35 `COMMENT` chunks so their text is searchable, not dropped; a comment above36 a definition folds into it as documentation. See *Comment handling*.37- Legitimate skips: truly anonymous nodes with no name a human would search38 (a `tree_sitter_query` pattern with no outer label — captured only as an39 anonymous section), pure metadata (`COMMENT ON`), usage/references (which40 belong to edges, not symbol chunks), and **runtime-generated symbols**41 (per above).42- When unsure, capture it. A spurious chunk is cheaper than an invisible43 symbol.4445### Contained definitions & the data-scope exception4647A definition that lives *inside* a larger definition we already chunk is48represented **by that parent chunk** — it is full-text-findable there and does49not get its own chunk. Statements inside a function, and **data members of a50class** (class attributes, enum members, dataclass fields), are represented by51the enclosing function/class chunk. **Methods are the exception**: substantial,52separately-navigable units, so they *do* get their own chunk.5354**The data-scope exception (language philosophy).** *Variable* capture targets55the language's idiomatic **top-level data scope**, not nested members — and56what counts as "top-level" differs by language:5758- Languages with a module / file / package data scope (Python module,59 Go/Rust/C file or package, JS/TS module, Bash script) capture variables60 **there**; class/struct data members stay contained in their parent chunk.61- **Class-only languages with no top-level data scope (Java): the class is the62 only namespace, so fields *are* the top-level data definitions and are63 captured** (scoped to their class). Not capturing them would leave Java with64 zero variable chunks.6566This is **not** an inconsistency between languages — it is the *same* rule67(capture top-level data at the language's idiomatic scope) applied to68differing language philosophies. It keeps capture comprehensive without69exploding every class into per-field chunks, while never leaving a language's70data definitions invisible.7172## Architecture7374A plugin is a package (`rbtr-lang-<lang>`) exposing one or more75module-level `LanguageRegistration` values — each named by its language id76— through the `rbtr.languages` entry-point group; the `LanguageManager`77discovers them via `importlib.metadata` (no pluggy — languages are78single-dispatch by id). Core's bundled languages register the same way,79from core's own `pyproject`. `build_index` routes extraction down one of80three paths:81821. **Chunker** — a chunker attached via `@reg.chunker`: prose (markdown,83 rst) and SFCs' markup84 (svelte, vue). The chunker owns extraction. It takes an optional `ranges`85 and must set `parser.included_ranges` when given it, so it can also serve86 as an injection target for an embedded block (see below).872. **Query** — `reg.grammar_module` + a `QueryExtraction`: code, plus config/data88 whose scope a query can express (python, rust, …, json, css, html, toml,89 yaml, hcl, tree_sitter_query). Goes through `extract_symbols`.90 HTML captures its semantic elements (`head`, `body`, sectioning content,91 landmarks) as doc sections, named by `id` else tag via a `name_extractor`.92 The `tree_sitter_query` plugin indexes `.scm` files themselves: each93 top-level pattern is a `@doc_section`, named by its own outer capture else94 anonymous.953. **Plaintext fallback** — no grammar/detection: fixed-size raw chunks.9697`extract_symbols` is the query engine: *parse → run query → captures →98`Chunk`s*. It takes the `LanguageRegistration` and delegates naming,99scoping, and imports to it (`reg.resolve_name` / `resolve_scope` /100`resolve_import`), each calling the language's resolver — the built-in, or an101override composed over it.102103**Injection (embedded languages)** is an orthogonal capability that runs *in104addition* to the primary path. To extract code embedded in a host file (an105SFC's `<script>`/`<style>`, a Markdown fenced block, an HTML inline106`<script>`/`<style>`), set `reg.injection_query`: a tree-sitter query over the107host grammar that captures each embedded block as `@injection.content` and108names its target language one of two ways:109110- **Static** — `(#set! injection.language "<id>")`, for a closed set (SFC/HTML111 `<script>`→js/ts, `<style>`→css), with an optional112 `(#set! injection.priority "<n>")` so a `lang`-tagged rule beats a bare one.113- **Dynamic** — capture the language name as `@injection.language` (a Markdown114 fence's info string). The engine resolves the captured text via the115 registry's own id and extension maps (`python`/`py` both reach python); an116 unknown hint is left unparsed. No per-language mapping table.117118The engine delegates each block's range to the target's *full* primary119extraction (`extract_primary` — chunker or query, so a chunker target like120yaml/toml works, not just query targets) and *recurses* into the target's own121injection (an HTML block containing an inline `<script>` yields its js), all122at absolute line numbers. Every file also gets a host-language chunk (a123content-less presence chunk if it would produce none), so dedup works. See124ARCHITECTURE “Dispatch chain” for the mechanism and rationale.125126### Where queries live127128Every query — `reg.extraction.query`, `reg.injection_query`, and any query a chunker129compiles — is a `.scm` file co-located in the plugin package130(`rbtr_lang_<lang>/<name>.scm`), loaded at import via `load_query` (import131it: `from rbtr.languages.registration import load_query`; call it:132`load_query(__package__, "<name>")`). Call it directly in the `extraction` or133`injection_query` field — never hoist it into a module-level `_QUERY` constant;134`load_query` is cached on `(package, name)`, so a query shared by two135registrations (svelte and vue's SFC injection query) is read once. Never inline136a query as a Python137string literal (the house rule against embedding a foreign language). The138`uv` build backend ships `.scm` as package data with no extra config.139140Compose in Python when a query is built from parts — the query language has141no `#include`: js/ts concatenate shared fragment files with `+`142(`load_query(pkg, "javascript") + load_query(pkg, "shared") + …`); SQL groups143its DDL verbs into `[...]` alternations within one `sql.scm`. Prefer these to144generating query text from Python data. Editing a `.scm` is an extraction145change — the `extraction_serial` bump rule (below) applies.146147## Capture conventions148149The query's capture names drive the chunk kind (see `_CAPTURE_KINDS` in150`languages/treesitter.py`):151152- `@function` / `@_fn_name` — functions153- `@class` / `@_cls_name` — classes, structs, enums, traits, types, and154 named collections of declarations (CSS rule sets, `@media`, `@keyframes`)155- `@method` / `@_method_name` — methods (a `@function` whose nearest scope is156 class-like is also promoted to a method)157- `@variable` / `@_var_name` — module/top-level variables, constants, fields158- `@import` — import statements (metadata via `import_extractor`)159- `@_scope` — optional: a node whose **text** becomes the symbol's innermost160 scope segment, for scopes lexical nesting can't reach (e.g. a Go method's161 receiver type). Strictly additive: absent → no effect.162- `@_docstring` — interior first-statement docstring (Python)163- `@doc_section` / `@_section_name` — chunker/data section units164- `@config_key` / `@_section_name` — config/data keys (JSON object keys, TOML165 tables, YAML mapping keys, HCL blocks, CSS `@charset`), reusing the166 `@_section_name` name capture167- `@comment` — top-level comments and module docstrings; grouped into blocks168 and either folded into the definition below or emitted as standalone169 `COMMENT` chunks (see *Comment handling*)170171Capture names starting with `_` are read but never become chunks.172173The display name comes from the paired `@_*_name` capture via the built-in174name resolver. When a query cannot express the name, attach a175`name_extractor` — `@reg.name_extractor` for a single-use local override,176or `reg.name_extractor(fn)` for a shared/imported one — as a **last177resort**. It is wrap-style (pydantic `WrapValidator` shape): signature178`(resolver, capture_name, node, captures) -> str`, where `resolver` is the179built-in resolver handed in; call it to delegate the cases you don't180special-case, exactly as an `import_extractor` receives the built-in import181resolver. Bash strips the `=` the grammar fuses onto an alias; HTML names an182element by its `id`, else its tag.183184The scope address comes from tree ancestry (`scope_types`, below) plus the185`scope_extractor` — the scope twin of `name_extractor`, whose built-in186resolver contributes the `@_scope` capture. Attach a custom187one the same way (wrap-style signature188`(resolver, capture_name, node, captures) -> list[str]`, outermost-first)189as a **last resort**, for a hierarchy neither ancestry nor `@_scope` can190reach; its segments are appended to the ancestry scope. Two real cases:191192- CSS/SCSS/Less nested rules — walk the ancestor `rule_set` selectors so193 `.card { .title { … } }` scopes `.title` under `.card`:194195 ```python196 def css_nesting_scope(_resolver, capture_name, node, captures):197 segments: list[str] = []198 for rule_set in enclosing_nodes_of_type(node, frozenset({"rule_set"})):199 for child in rule_set.children:200 if child.type == "selectors" and child.text:201 segments.append(child.text.decode().strip())202 break203 return segments204 ```205206- TOML dotted tables — the hierarchy is a dotted-key *string*, not tree207 ancestry, so a `name_extractor` returns the last segment and a208 `scope_extractor` the preceding ones (`[tool.ruff]` → name `ruff`, scope209 `tool`).210211## Comment handling212213Comments are captured, not configured. Give your grammar's comment node(s) a214**root-scoped `@comment`** capture so only top-level comments match — a comment215inside a function body stays part of that body's chunk:216217- most grammars: `(translation_unit (comment) @comment)`, `(program (comment)218 @comment)`, `(source_file (comment) @comment)`, `(stylesheet (comment)219 @comment)` — use the grammar's actual root and comment node type;220- multiple comment node types go in an alternation: Rust/Java `[(line_comment)221 (block_comment)]`, SQL `[(comment) (marginalia)]`, SCSS/Less `[(comment)222 (js_comment)]`;223- Python also captures its module docstring:224 `(module (expression_statement (string) @comment))`.225226The engine does the rest, identically for every language: it groups top-level227comment runs into blank-line-delimited blocks, folds a block flush above a228definition into that definition, leaves interior comments in their body, and229emits everything else as a standalone `COMMENT` chunk. You only declare the230node types. A comment trailing code on its line documents that statement and231never folds forward. The routing rules and their rationale live in ARCHITECTURE.232233## Scope & promotion (engine layers — already generic)234235Set on the language's `QueryExtraction` (the `extraction` field);236`extract_symbols` applies them to every captured node:237238- `scope_types` — node types that open a naming scope; composed into the239 `::` address. Include nesting containers (classes, namespaces, modules,240 functions where nested defs matter).241- `class_scope_types` — the subset that is class-like; a function directly242 inside one is promoted to a method. Defaults to `scope_types`.243- Non-lexical scope comes from `@_scope` (above) or, when even that can't244 reach it, a `scope_extractor` (above), not these.245246## Authoring or extending a plugin2472481. Read the grammar: its `queries/tags.scm` (the authors' definition list —249 see the tags reference) **and** the real node structure (parse a snippet,250 print the tree). Never guess node types.2512. Edit the language's `.scm` query file (or chunker) — queries live in252 the plugin package (`rbtr_lang_<lang>/*.scm`), loaded via `load_query`,253 never inline (see *Where queries live*). Verify against a parsed254 snippet.2553. Add the construct to that language's sample in the package's256 `tests/samples/` and regenerate the snapshot with `just snapshots`;257 review the diff.2584. Bump `extraction_serial` — any extraction change triggers259 re-extraction of stored blobs. It is independent of the package260 version: bump it during development (before any release) when output261 changes, and never bump it for a package-only release or pure move.2625. `just check`. (Samples are exempt from lint/type/format — see below.)263264## Gotchas (all learned the hard way; verified)265266- **Require a body on type captures.** `(struct_specifier name: …)` matches267 *references* too (e.g. inside `typedef struct G G;`, or a parameter type),268 producing spurious class chunks. Require `body:` so only definitions match269 (C/C++ struct/enum/class).270- **Take a span's last line from `last_line`.** Tree-sitter rows are271 0-based, so `end_point[0] + 1` is right for a node ending mid-line and272 one too many for a node that consumes its trailing newline and ends at273 column 0 of the next row. Ten sites once computed this three ways: a274 toml table claimed the next table's header line, a rust comment block275 lost its last line, and a heading-less rst paragraph ran backwards276 (line 7 to line 6). Import `last_line` from `rbtr.languages.chunks`277 and delete any local trailing-newline correction — a compensation left278 beside it decrements twice.279- **Non-lexical scope → `@_scope`.** Scope is otherwise lexical-ancestry only280 (`_enclosing_scopes` walks parents). A Go method's receiver is a *child*, so281 capture it as `@_scope`.282- **Determinism in test helpers.** `next(iter(reg.extensions))` over a283 `frozenset` varies with `PYTHONHASHSEED`; the chunk id hashes `file_path`,284 so derive paths deterministically (`sorted(...)`, or pass an explicit path).285- **Imports are always bespoke.** `tags.scm` has no imports; the edge system286 depends on them. Keep per-language `import_extractor`s.287- **SQL / multi-dialect: don't gate on parse-clean.** One generic SQL grammar288 serves all `.sql`. Tree-sitter error recovery is *local* — a dialect289 construct it can't parse breaks only its own subtree; surrounding290 statements still extract. Treat `has_error` as informational, not a gate.291- **Known-unsupported constructs → strict xfail.** Record a construct that292 *should* extract but can't (grammar/plugin limit) as an `xfail(strict=True)`293 case, so closing the gap flips the test and prompts an update. Reserve for294 symbol-shaped gaps `(kind, name, scope)`; import-identity gaps don't fit.295296## `tags.scm`: a reference, not a runtime source297298Every code grammar ships `queries/tags.scm` — the authors' standard299definition/reference query (`@definition.*` / `@name` / `@reference.*`). It is300itself a tree-sitter query, so it is **inspiration for ours, not a drop-in**:301302- **Mine it**: take good patterns, modify weak ones, ignore wrong ones.303 Verify every pattern against a real parse — quality is uneven (e.g. C's304 `union` pattern misses standalone `union U {}`).305- It captures **no imports and few variables**, omits some constructs306 (TS `enum`/type alias, Java `enum`/`record`), some grammars inherit others307 (ts ← js), and only the 9 code grammars ship one.308- Running it live would couple extraction to upstream drift. We don't.309310Distinct from **rbtr's own** `.scm` files (see *Where queries live*): we load311those at runtime (`reg.extraction.query` / `reg.injection_query`) as the312source of truth, whereas `tags.scm` we only mine for ideas. And the313`tree_sitter_query` plugin now *indexes* `.scm` files found in a repo —314including third-party `tags.scm` / `highlights.scm` / `injections.scm` — as315content, orthogonal to whether we316run them.317318Curated per-language verdicts (take / modify / ignore) and the319`@definition.* → ChunkKind` mapping live in320`references/tags-scm-reference.md`.321322## Testing323324Each language has a sample mini-project under its package's `tests/samples/`325(one or more files), golden-snapshotted (full `model_dump_json`),326coverage-checked, and parse-clean-checked. See **rbtr-testing**. Samples are327exempt from the repo's linters/type-checker (they're fixtures) — validated328only by their own tests.