Metadata-Version: 2.4
Name: rbtr
Version: 2026.9.0.dev3
Summary: rbtr — structural code index
Keywords: code-search,code-index,tree-sitter,static-analysis,semantic-search,developer-tools
Author: Alejandro Giacometti
Author-email: Alejandro Giacometti <alejandro.giacometti@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Text Processing :: Indexing
Classifier: Typing :: Typed
Requires-Dist: duckdb>=1.0
Requires-Dist: dataframely>=2.9.1
Requires-Dist: huggingface-hub
Requires-Dist: llama-cpp-python>=0.3.24
Requires-Dist: pathspec>=1.0.4
Requires-Dist: platformdirs>=4.0
Requires-Dist: pyarrow>=17.0
Requires-Dist: pydantic>=2.12
Requires-Dist: pydantic-settings[toml]>=2.12
Requires-Dist: pygit2
Requires-Dist: pyzmq>=27.1.0
Requires-Dist: rich>=13.0
Requires-Dist: rich-argparse>=1.7.2
Requires-Dist: structlog>=24.1
Requires-Dist: rbtr-lang-bash
Requires-Dist: rbtr-lang-c
Requires-Dist: rbtr-lang-cpp
Requires-Dist: rbtr-lang-html
Requires-Dist: rbtr-lang-javascript
Requires-Dist: rbtr-lang-markdown
Requires-Dist: rbtr-lang-python
Requires-Dist: rbtr-lang-rst
Requires-Dist: tree-sitter>=0.24
Requires-Dist: rbtr-lang-css ; extra == 'all'
Requires-Dist: rbtr-lang-scss ; extra == 'all'
Requires-Dist: rbtr-lang-less ; extra == 'all'
Requires-Dist: rbtr-lang-json ; extra == 'all'
Requires-Dist: rbtr-lang-toml ; extra == 'all'
Requires-Dist: rbtr-lang-yaml ; extra == 'all'
Requires-Dist: rbtr-lang-hcl ; extra == 'all'
Requires-Dist: rbtr-lang-go ; extra == 'all'
Requires-Dist: rbtr-lang-java ; extra == 'all'
Requires-Dist: rbtr-lang-ruby ; extra == 'all'
Requires-Dist: rbtr-lang-rust ; extra == 'all'
Requires-Dist: rbtr-lang-sql ; extra == 'all'
Requires-Dist: rbtr-lang-tree-sitter-query ; extra == 'all'
Requires-Dist: rbtr-lang-svelte ; extra == 'all'
Requires-Dist: rbtr-lang-vue ; extra == 'all'
Requires-Dist: rbtr-lang-css ; extra == 'css'
Requires-Dist: rbtr-lang-go ; extra == 'go'
Requires-Dist: rbtr-lang-hcl ; extra == 'hcl'
Requires-Dist: rbtr-lang-java ; extra == 'java'
Requires-Dist: rbtr-lang-json ; extra == 'json'
Requires-Dist: rbtr-lang-less ; extra == 'less'
Requires-Dist: rbtr-lang-ruby ; extra == 'ruby'
Requires-Dist: rbtr-lang-rust ; extra == 'rust'
Requires-Dist: rbtr-lang-scss ; extra == 'scss'
Requires-Dist: rbtr-lang-sql ; extra == 'sql'
Requires-Dist: rbtr-lang-svelte ; extra == 'svelte'
Requires-Dist: pytest<9.1 ; extra == 'test'
Requires-Dist: pytest-cases>=3.10.1 ; extra == 'test'
Requires-Dist: syrupy>=5.3.4 ; extra == 'test'
Requires-Dist: rbtr-lang-toml ; extra == 'toml'
Requires-Dist: rbtr-lang-tree-sitter-query ; extra == 'tree-sitter-query'
Requires-Dist: rbtr-lang-vue ; extra == 'vue'
Requires-Dist: rbtr-lang-yaml ; extra == 'yaml'
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/janrito/rbtr
Project-URL: Repository, https://github.com/janrito/rbtr
Project-URL: Documentation, https://github.com/janrito/rbtr/tree/main/packages/rbtr#readme
Project-URL: Issues, https://github.com/janrito/rbtr/issues
Project-URL: Changelog, https://github.com/janrito/rbtr/releases
Provides-Extra: all
Provides-Extra: css
Provides-Extra: go
Provides-Extra: hcl
Provides-Extra: java
Provides-Extra: json
Provides-Extra: less
Provides-Extra: ruby
Provides-Extra: rust
Provides-Extra: scss
Provides-Extra: sql
Provides-Extra: svelte
Provides-Extra: test
Provides-Extra: toml
Provides-Extra: tree-sitter-query
Provides-Extra: vue
Provides-Extra: yaml
Description-Content-Type: text/markdown

# rbtr

A language-agnostic structural code index. rbtr decomposes
source files into functions, classes, methods, variables, and
imports, connects them with a dependency graph, and makes them
searchable through name matching, keyword search, and
semantic similarity — fused into a single ranked result.

## Quick start

```bash
uv tool install rbtr         # install the CLI

cd /path/to/your/repo
rbtr watch                   # watch HEAD and index it
rbtr search "retry logic"    # search it
```

### Hardware acceleration

The default installation builds `llama-cpp-python` from PyPI for the local
machine. To use a prebuilt accelerator backend, select one of the upstream
indexes. Their URLs follow
`https://abetlen.github.io/llama-cpp-python/whl/<backend>`.

For example, install the CUDA 12.4 wheel:

```bash
uv tool install \
  --index https://abetlen.github.io/llama-cpp-python/whl/cu124 \
  "rbtr[all]"
```

Or install the Vulkan wheel:

```bash
uv tool install \
  --index https://abetlen.github.io/llama-cpp-python/whl/vulkan \
  "rbtr[all]"
```

Choose a backend compatible with the machine's drivers and platform. See the
[llama-cpp-python installation documentation][llama-install] for the current
backend names and requirements.

[llama-install]: https://github.com/abetlen/llama-cpp-python#installation

A background daemon starts automatically and keeps every
watched ref current (`HEAD` by default; add more with
`rbtr watch <ref>`). Subsequent builds are incremental —
unchanged files (by blob SHA) are skipped.

## Walkthrough

Index a repository, then explore it. The transcripts below
come from rbtr's own source; line numbers and scores move as
the code does.

```text
rbtr watch
Watching: HEAD
Indexing in background; run `rbtr status` to track.

rbtr search "search fusion"
ARCHITECTURE.md:447-468  doc_section  Search fusion
  0.49
    Three channels fused into one ranked list:
    …

src/rbtr/index/search.py:96-127  function  classify_query
  0.33
    def classify_query(query: str) -> QueryKind:
    …
```

Read the top hit's source:

```text
rbtr read-symbol fuse_scores
src/rbtr/index/search.py:298-380  function  fuse_scores
 298 def fuse_scores(
 299     scored: dy.DataFrame[FusionInputRow],
 300     query: str,
 301     ...
```

See what changed between two refs:

```text
rbtr changed-symbols HEAD~3 HEAD
+ doc_section CLI integration  ARCHITECTURE.md
~ function   fuse_scores  src/rbtr/index/search.py
− function   resolveCommand  exec.ts

+1  ~1  −1
```

List symbols in a file:

```text
rbtr list-symbols src/rbtr/index/search.py
    43-43    variable    log
    44-86    function    _name_score_expr
    95-127   function    _kind_boost_expr
   298-380   function    fuse_scores
   381-494   function    search
```

## Commands

The read commands — `search`, `read-symbol`, `list-symbols`,
`find-refs` — share a `--ref`. Left out, it reads your working
tree when it is dirty and `HEAD` when it is clean, so results
reflect uncommitted edits without being asked to. Given
explicitly, that ref must be indexed: an unindexed ref you
named is an error rather than a quiet answer from a different
one.

### `rbtr watch`

Watch refs and keep them indexed. Each positional ref is an
independent watch target the daemon keeps current; with no
arguments it watches `HEAD`.

```bash
rbtr watch                    # watch HEAD (the default)
rbtr watch main               # watch main, even from another branch
rbtr watch main release       # watch several refs independently
```

A moving ref (branch) tracks its tip; a bare SHA settles after
one build.

What you watch and what is stored are separate things, and so are
the commands that change them: `rbtr unwatch` and `rbtr forget`
both edit bookkeeping only, and leave the indexed data where it
is. `rbtr gc` is what reclaims the space.

### `rbtr unwatch`

Stop watching refs — the ones you name, or the ones git has lost.

```bash
rbtr unwatch main             # stop watching main (HEAD can't be removed)
rbtr unwatch --stale          # ...and the deleted branches, found for you
rbtr unwatch --stale --scope all   # ...in every indexed repo
rbtr unwatch --stale --dry-run     # report, change nothing
```

The index those refs built is reclaimed by `rbtr gc --keep
watched-only`; a plain `rbtr gc` keeps every branch and tag
regardless. A repo whose path is merely unreachable for now is
left alone: git cannot answer for it, so every ref it watches
would look stale.

### `rbtr forget`

Forget a repo's index: its watch set, indexed commits, and
references.

```bash
rbtr forget                   # this repo (only when HEAD is all it watches)
rbtr forget --stale           # every repo whose checkout is gone
rbtr forget --stale --dry-run # report, change nothing
```

Run `--stale` from anywhere, including outside a git repo: a
deleted worktree or clone can no longer be named, only found.

### `rbtr search <query>`

Search the code index.

```bash
rbtr search "IndexStore"          # name match
rbtr search "retry timeout"       # keyword search
rbtr search "how does auth work"  # semantic search
```

Combines name, keyword, and semantic search into a
single ranked result. See
[ARCHITECTURE.md][arch-search-fusion]
for the fusion algorithm.

[arch-search-fusion]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md#search-fusion

Supply `--keywords` and `--variants` (both repeatable) to
widen retrieval — keywords extend the lexical query,
variants add semantic rephrasings:

```bash
rbtr search "load config" \
  --keywords settings --keywords env \
  --variants "read configuration from file"
```

Pass `--scope all` to search every indexed repo in the
shared store, not just the current one. Results from all
repos merge into one ranked list, each prefixed with its
repo name:

```bash
$ rbtr search "connection pool" --scope all
ukf/deploy/pgbouncer/settings.env:1-9  config  pgbouncer
  0.84
rbtr/packages/rbtr/src/rbtr/index/store.py:118-140  method  IndexStore.close
  0.23
```

Scope defaults to `workspace` (the current repo only).

### `rbtr read-symbol <name>`

Full source of a symbol by name.

```bash
rbtr read-symbol fuse_scores
```

When a name lives in several files, narrow it with
`--file-path` (repeatable):

```bash
rbtr read-symbol Config --file-path src/auth/config.py
```

### `rbtr list-symbols <file>`

Table of contents for a file — one line per symbol.

```bash
rbtr list-symbols src/rbtr/index/search.py
```

### `rbtr find-refs <symbol>`

Symbols that reference a given symbol via the dependency
graph (imports, docs).

```bash
rbtr find-refs IndexStore
```

Disambiguate a colliding name by restricting resolution to
certain files with `--file-path` (repeatable):

```bash
rbtr find-refs Config --file-path src/auth/config.py
```

### `rbtr changed-symbols <base> <head>`

Symbols in files that changed between two refs.

```bash
rbtr changed-symbols HEAD~5 HEAD
```

Scope the diff to specific files with `--file-path`
(repeatable):

```bash
rbtr changed-symbols HEAD~5 HEAD --file-path src/rbtr/index/store.py
```

### `rbtr status`

Index status: indexed refs, chunk counts, the on-disk index
size, and active builds.

```bash
$ rbtr status
✓  5.8k chunks  1.3 GB · ~/.local/share/rbtr/index.duckdb
   aa2ecc4bbefb (HEAD, main)  5.8k indexed  5.8k embedded ✓
```

The chunk count is this repo's; the size is the whole file,
which every indexed repo shares. Most of it is embeddings —
one vector per chunk, and they dominate everything else
stored. `--scope all` breaks the count down per repo.

Pass `--scope all` to list every indexed repo in the
shared store, grouped by repo:

```bash
$ rbtr status --scope all
✓  indexed repos  1.3 GB · ~/.local/share/rbtr/index.duckdb
  /home/me/projects/ukf
     a4aa7830ad87 (HEAD, main)  68.8k indexed  68.8k embedded ✓
  /home/me/projects/rbtr
     aa2ecc4bbefb (HEAD)  5.8k indexed  5.8k embedded ✓
```

### `rbtr config`

The configuration in force and the language plugins loaded.
Reports the running daemon's live config when one is up,
otherwise what a daemon would load from this directory,
noting which on stderr.

```bash
rbtr config
```

### `rbtr daemon`

```bash
rbtr daemon start     # start the daemon
rbtr daemon stop      # stop it
rbtr daemon status    # show state and build progress
```

Starts automatically on first `rbtr watch` or `rbtr search`.

### `rbtr gc`

Garbage-collect old index data. **Destructive and not undoable** —
it permanently deletes indexed commits/chunks. Always preview with
`--dry-run` first. It is only ever manual; the daemon never GCs on
its own.

```bash
rbtr gc                           # this repo (keeps branches/tags + watch set)
rbtr gc --scope all               # every indexed repo
rbtr gc --keep watched-only       # keep only HEAD and watched refs
rbtr gc --keep head-only          # keep only HEAD
rbtr gc --keep-refs main,release  # keep only HEAD plus these refs
rbtr gc --keep everything         # sweep crashed-build residue only
rbtr gc --no-compact              # skip the disk-reclaiming rewrite
rbtr gc --dry-run                 # preview what would be dropped
```

`rbtr gc` collects the current repo by default. `--scope all` reclaims
across **every** indexed repo at once — useful because chunks are shared
between repos, keeping `watched` or `watched-only`. `head-only`
and `--keep-refs` name one repo's refs, so scope those with
`--repo-path`. (The chunk sweep is global on every gc
regardless, so a plain `rbtr gc` still frees chunks no other repo
references.)

By default it keeps HEAD, every local branch and tag, and
every watched ref (plus the current worktree), dropping only
genuinely unreferenced commits — so a routine gc never
discards anything still reachable. `--keep watched-only` keeps
just HEAD and the watch set, dropping unwatched branches and
tags (the way to reclaim refs you no longer index).

The rest: `--keep head-only` keeps only HEAD; `--keep-refs`
keeps HEAD plus the refs you name; `--keep everything` drops no
commits at all and sweeps residue from crashed builds. A run
keeps one set, so naming a second is refused.

If the daemon is mid-build or mid-embed when you run it, gc waits
for that work to commit before it starts — usually a second or two,
longer if a large repo is building.

After deleting, gc rewrites the index file to hand the freed disk
space back to the operating system — deleting alone keeps that space
inside the file, so it never shrinks on its own. The rewrite reports
the size change (`index 2.08 GB → 1.28 GB (-800 MB)`). Pass
`--no-compact` to skip it.

**When to run it.** Never on a schedule — the daemon does not
collect on its own, and a healthy index does not need it. Two
situations call for it: the file has grown past what you want
to give it, or you have stopped indexing refs and want the
space back (`--keep watched-only`, after `rbtr unwatch --stale
--scope all` has dropped the branches that no longer exist).

Growth is driven by embeddings, one vector per chunk, so the
size tracks how many distinct chunks every indexed repo holds
between them — not how many repos there are. Branches sharing
most of their content cost little; a long-lived branch that
diverges widely, or a second unrelated repo, costs
proportionally. `rbtr status --scope all` shows the split.

## Output modes

- **TTY**: rich-formatted text with syntax highlighting.
- **Piped / `--json`**: a single JSON object — the full response
  model, serialised in one pass (the same shape the daemon returns).

Example from `rbtr search --json`:

```json
{"kind":"search","results":[{"name":"fuse_scores","kind":"function","file_path":"src/rbtr/index/search.py","score":0.49,...}]}
```

See [Daemon protocol][arch-daemon-protocol]
for the full response models.

[arch-daemon-protocol]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md#daemon-protocol

## Logs

The daemon writes structured logs — one JSON object per line — to
`daemon.log` in the log directory, rotating at 10 MB and keeping five
backups. CLI commands log to stderr: coloured on a terminal, JSON when
piped. stdout is reserved for command output, so logs never pollute a
`--json` result.

Raise verbosity with `--log-level` or the `RBTR_LOG_LEVEL`
environment variable:

```bash
rbtr --log-level debug search "retry logic"   # DEBUG to stderr
RBTR_LOG_LEVEL=debug rbtr status              # same, via env
```

`rbtr config` shows the log directory (`log_dir`); tail the daemon log
with any JSON-aware tool:

```bash
tail -f <log_dir>/daemon.log
```

See [ARCHITECTURE.md][arch-observability] for the logging
pipeline and how requests are correlated.

[arch-observability]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md#observability

## Configuration

`{config_dir}/config.toml`. Environment variables with
`RBTR_` prefix override file values. Run `rbtr config` to
see the full rendered config with all defaults.

Notable settings:

- `embedding_model` — HuggingFace GGUF model ID.
- `search_weights` — per-query-kind fusion weights
  `(alpha, beta, gamma)` for the semantic, lexical, and
  name-match channels.
- `reranker_model` — cross-encoder GGUF model ID. Set to
  `""` to disable reranking.
- `reranker_settings` — per-query-kind reranker pool size
  and blend weight.
- `log_level` — root log level (`DEBUG`, `INFO`, …).
- `log_format` — `auto` (console on a TTY, else JSON), `console`,
  or `json`.
- `log_max_bytes` / `log_backup_count` — `daemon.log` rotation size
  and backup count.

Run `rbtr config` to see every field with its current
value.

Query expansion (keywords and variant rephrases) is
client-supplied: the caller passes `keywords` and `variants`
on `SearchRequest`. In pi sessions the LLM generates these
automatically via tool-call parameters.

Directories are resolved via [platformdirs]. Four are
independently overridable (`--data-dir`, `--config-dir`,
`--log-dir`, `--cache-dir`). `runtime_dir` is derived from
`hash(data_dir)` — never overridable.

[platformdirs]: https://platformdirs.readthedocs.io/

## Supported languages

Languages with tree-sitter grammars get structural
extraction (symbol-level chunks, import metadata, scope
detection). Everything else gets line-based chunking, so it
stays searchable without structure.

Each language is a separate package. `rbtr config` lists the
ones this install loaded; the [repository
README][repo-languages] has the full set with the
extra to install for each.

[repo-languages]: https://github.com/janrito/rbtr/blob/main/README.md#languages

Comments are indexed too. A comment block above a definition
becomes part of that definition's chunk; one standing on its
own — a banner, a licence header, a note between functions —
becomes its own searchable chunk.

Code embedded in another file is indexed in its own
language. A fenced code block in Markdown is extracted as
chunks of that language at its real line numbers, so a
Python example in a README is searchable as Python.
HTML and single-file components (Svelte, Vue) extract
inline `<script>` / `<style>` the same way; an SFC's markup
template is indexed too, named after the component file.

See [ARCHITECTURE.md][arch-language-plugins]
for how the plugin system works.

[arch-language-plugins]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md#language-plugins

## Writing a language plugin

rbtr's core ships no languages of its own — each is a separate,
installable package, `rbtr-lang-<lang>`, that registers with rbtr
through a `rbtr.languages` entry point. Installing the package is what
registers it; nothing else does.

Most of a plugin is optional. Only `id` is required, and for most
languages a query and a grammar are all you add on top.

### The smallest plugin that works

Two files and an entry point. The query says which nodes to capture:

```scm
; src/rbtr_lang_swift/swift.scm
(function_declaration
  name: (identifier) @_fn_name) @function
(class_declaration
  name: (type_identifier) @_cls_name) @class
(import_declaration
  (identifier) @_import_module) @import
(source_file
  (comment) @comment)
```

The registration names the language and points at that query:

```python
# src/rbtr_lang_swift/plugin.py
from __future__ import annotations
from rbtr.languages.registration import (
    LanguageRegistration,
    QueryExtraction,
    load_query,
)

swift = LanguageRegistration(
    id="swift",
    extensions=frozenset({".swift"}),
    grammar_module="tree_sitter_swift",
    extraction=QueryExtraction(
        query=load_query(__package__, "swift"),
        scope_types=frozenset({"class_declaration"}),
    ),
)
```

And the entry point tells rbtr where to find it:

```toml
[project.entry-points."rbtr.languages"]
swift = "rbtr_lang_swift.plugin:swift"
```

That is a complete plugin. The generic `extract_symbols` pipeline does
the rest: parsing, capture matching, scope detection, chunk
construction, and comment grouping. Everything below is for languages
that need more than this.

### The package

Lay the package out as a standard `src` distribution:

```text
rbtr-lang-swift/
  pyproject.toml
  README.md
  src/rbtr_lang_swift/
    __init__.py
    plugin.py
    swift.scm       # tree-sitter query, shipped as package data
    py.typed
```

```toml
# pyproject.toml
[build-system]
requires = ["uv_build>=0.11.26,<1"]
build-backend = "uv_build"

[tool.uv.build-backend]
module-name = "rbtr_lang_swift"

[project]
name = "rbtr-lang-swift"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["rbtr", "tree-sitter-swift"]

# How rbtr discovers the plugin: the value points at a module-level
# `LanguageRegistration`, named by its language id.
[project.entry-points."rbtr.languages"]
swift = "rbtr_lang_swift.plugin:swift"

[dependency-groups]
dev = ["rbtr[test]"]   # the test harness (syrupy + pytest-cases)
```

### Capture conventions

A capture's name decides the chunk kind. Eight produce chunks:

| Capture        | Name capture      | Produces                                                                             |
| -------------- | ----------------- | ------------------------------------------------------------------------------------ |
| `@function`    | `@_fn_name`       | a function                                                                           |
| `@method`      | `@_method_name`   | a method (a `@function` whose nearest scope is class-like is promoted automatically) |
| `@class`       | `@_cls_name`      | a class, struct, enum, trait, or other named collection of declarations              |
| `@variable`    | `@_var_name`      | a module-level variable or constant                                                  |
| `@import`      | `@_import_module` | an import, with metadata for the edge graph                                          |
| `@doc_section` | `@_section_name`  | a prose section                                                                      |
| `@config_key`  | `@_section_name`  | a config or data key — JSON object keys, TOML tables, YAML mappings, HCL blocks      |
| `@comment`     | —                 | a comment; see below                                                                 |

Two helper captures do not produce chunks of their own:
`@_scope` contributes a scope segment that lexical nesting cannot
reach (a Go method's receiver type), and `@_docstring` marks an
interior docstring. Any capture starting with `_` is read but never
becomes a chunk.

**Imports.** `@_import_module` populates `ImportMeta.module` straight
from the query, stripping `<>` and `"` delimiters. Each `@import`
match then passes through the language's import resolver, which reads
captures first and walks the node for what the query cannot express,
such as multi-valued import names. `ImportMeta.language_hint` directs
resolution when the target is a different language — an HTML
`<script src>` pointing at JavaScript, say.

**Comments.** Capture your grammar's comment nodes as `@comment`,
scoped to the file root, plus the module docstring where the language
has one. A block directly above a definition folds into that
definition's chunk; a block standing on its own becomes a `comment`
chunk; a comment trailing code stays with that statement. The engine
does this identically for every language — see
[ARCHITECTURE][arch] for the rules.

[arch]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md

### When a query is not enough

Four overrides handle what captures cannot express. Each attaches to
the registration as a decorator, or as a plain call to reuse an
existing function:

| Override           | For                                                                                                   |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| `name_extractor`   | a display name the query cannot capture — an HCL block named by its type and labels                   |
| `scope_extractor`  | a scope tree ancestry cannot reach — a CSS nested rule under its parent selector, a TOML dotted table |
| `import_extractor` | import metadata needing a node walk                                                                   |
| `chunker`          | a whole language whose structure captures cannot express                                              |

The first three are **wrap-style**, like pydantic's `WrapValidator`:
the first argument is the built-in resolver, so you delegate to it and
refine the result rather than importing the default.

```python
@swift.name_extractor  # fresh, inline
def swift_name(
    resolver: NameResolver, capture_name: str, node: Node, caps: dict[str, list[Node]]
) -> str:
    return resolver(capture_name, node, caps).removesuffix("!")  # delegate, then tweak


swift.import_extractor(extract_swift_imports)  # reuse an existing function
```

An override that does not delegate simply ignores its `resolver`.
Unset ones fall back to the engine defaults.

### How your language's references resolve

An `@import` capture gives the edge graph a module string. Turning that
into a file is the registration's job, and every language spells it
differently:

| Field                | Says                                                                                         |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `module_style`       | whether a reference is a path (`./b`, `b.css`) or a dotted name (`pkg.b`)                    |
| `source_roots`       | directory prefixes tried in turn, for a language whose imports are written from a `src` root |
| `path_substitutions` | prefix rewrites, for an alias that is not a directory — Rust's `crate::` under `src/`        |
| `index_files`        | the file that stands for a directory: `__init__.py`, `index.js`, `mod.rs`, `Cargo.toml`      |
| `package_directory`  | that a directory *is* a unit, so a reference to one reaches every file in it                 |
| `manifest`           | a repository file whose text supplies further substitutions, read per repository             |

A path-style reference naming no directory is resolved beside the file
making it, so `@import "b.css"` in `css/a.css` reaches `css/b.css`. Set
`package_directory` only where the language means the whole directory — a
Go package, a Terraform module — since it otherwise makes a link to a
directory reach every file inside.

`manifest` is for a prefix written down in the repository rather than in
the language. Go declares one:

```python
go = LanguageRegistration(..., manifest="go.mod", package_directory=True)


@go.manifest_reader
def go_module_prefix(text: str) -> tuple[tuple[str, str], ...]:
    """`module example.com/m` makes `example.com/m/b` the directory `b`."""
    match = re.search(r"^\s*module\s+(\S+)", text, re.MULTILINE)
    return ((f"{match.group(1)}/", ""),) if match else ()
```

Add a case to `test_import_resolution.py` for the language: two real files
in a subdirectory, one referencing the other, asserting the edge lands.
Nested because two files at the repository root cannot tell a reference to
a sibling from a reference from the root.

### Chunker-based plugin

When the language's structural units can't be expressed
as query captures (heading hierarchies, composed names,
content-minus-children), write a custom chunker. The
chunker receives the grammar from the manager:

```python
# src/rbtr_lang_example/plugin.py
from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING
from tree_sitter import Parser
from rbtr.domain.models import Chunk, ChunkKind
from rbtr.languages.chunks import last_line
from rbtr.languages.registration import LanguageRegistration

if TYPE_CHECKING:
    from tree_sitter import Language, Range

example = LanguageRegistration(
    id="example",
    extensions=frozenset({".ex"}),
    grammar_module="tree_sitter_example",
)


@example.chunker
def chunk_example(
    file_path: str,
    blob_sha: str,
    content: str,
    grammar: Language,
    ranges: list[Range] | None = None,
) -> Iterator[Chunk]:
    parser = Parser(grammar)
    if ranges is not None:
        parser.included_ranges = ranges  # serve as an injection target
    tree = parser.parse(content.encode())
    for node in tree.root_node.children:
        yield Chunk(
            blob_sha=blob_sha,
            file_path=file_path,
            kind=ChunkKind.DOC_SECTION,
            name="",
            scope="",
            content=node.text.decode(),
            line_start=node.start_point[0] + 1,
            line_end=last_line(node),
        )
```

Spans are 1-based and inclusive. Take the last line from
`last_line`: tree-sitter rows are 0-based, and a node that
consumes its trailing newline ends at column 0 of a row it does
not occupy, so adding one to the end row overshoots such a node
by a line.

### Testing the plugin

Tests exercise the **real** extraction pipeline: they call
`rbtr.languages.extract.extract_file` — the same per-file entry point
the indexer uses — so there is no test-only code path. The `rbtr[test]`
extra (in your `dev` group) provides `syrupy` and `pytest-cases`. Lay
the tests out beside the code:

```text
src/rbtr_lang_swift/tests/
  __init__.py
  cases_extraction.py
  test_extraction.py
  test_samples.py
  samples/swift/swift.swift
  __snapshots__/
```

The `snapshot_json` fixture — which serialises chunks to canonical JSON for
snapshots — is provided automatically by the `rbtr[test]` pytest plugin, so a
test just takes it as an argument; no `conftest.py` is needed.

Construct tests keep the data (source → expected symbols) in `@case`
functions and run the pipeline in the test body:

```python
# cases_extraction.py
from pytest_cases import case

type SymbolCase = tuple[str, str, list[tuple[str, str, str]]]


@case(tags=["symbol"])
def case_function() -> SymbolCase:
    return "swift", "func greet() {}\n", [("function", "greet", "")]


# test_extraction.py
from pytest_cases import parametrize_with_cases
from rbtr.git import FileEntry
from rbtr.languages.extract import extract_file


@parametrize_with_cases("lang, source, expected", cases=".cases_extraction", has_tag="symbol")
def test_extracts_expected_symbols(lang, source, expected):
    chunks = extract_file(FileEntry("input", "sha1", source.encode()), lang)
    got = [(c.kind, c.name, c.scope) for c in chunks]
    for exp in expected:
        assert exp in got
```

A sample test snapshots a committed example project, guarding extraction
against drift:

```python
# test_samples.py
from pathlib import Path
from rbtr.git import FileEntry
from rbtr.languages.extract import extract_file
from rbtr.languages.manager import get_manager


def test_extraction_matches_snapshot(snapshot_json):
    root = Path(__file__).parent / "samples" / "swift"
    files = [
        (str(p.relative_to(root)), p.read_text()) for p in sorted(root.rglob("*")) if p.is_file()
    ]
    manager = get_manager()
    chunks = []
    for path, text in files:
        lang = manager.detect_language(path) or "swift"
        chunks.extend(extract_file(FileEntry(path, "sha1", text.encode()), lang))
    assert chunks == snapshot_json
```

Regenerate the golden files after an intended change with
`pytest --snapshot-update`. For edge snapshots,
`rbtr.testing.render_edges(edges, chunks)` turns opaque edge
ids into readable `file::name -> file::name [kind]` lines.

### Installing the plugin

Installing the package is all it takes — the entry point auto-registers.
(Two packages claiming the same language id is a conflict and raises.)

- **Any third-party language:** `pip install rbtr-lang-swift` (it
  depends on `rbtr`).
- **A language rbtr blesses as an extra:** rbtr lists
  `swift = ["rbtr-lang-swift"]` in its own `[project.optional-dependencies]`,
  so `pip install rbtr[swift]` works.
- **A default language:** rbtr lists the package in its own
  `[project.dependencies]`, so plain `pip install rbtr` pulls it.

### Re-indexing after a plugin change

When you change a plugin's extraction logic — its query, chunker, or
anything that shapes the chunks it emits — bump
`extraction_serial` on the registration. Indexed chunks are keyed
by this serial, so a bump triggers re-extraction of every blob stored
at a different serial on the next build; leaving it unchanged keeps the
existing (now stale) chunks. It is independent of the package version —
bump it whenever extraction output changes, including during development
before a release. See
[ARCHITECTURE.md][arch-dedup]
for the dedup mechanism.

[arch-dedup]: https://github.com/janrito/rbtr/blob/main/packages/rbtr/ARCHITECTURE.md#content-addressed-chunks-and-blob-dedup

## Graceful degradation

- **No grammar** → line-based plaintext chunking.
- **No embedding model** → structural index works, semantic
  search skipped.
- **No client-supplied expansion** → search runs on the
  original query only (no keyword or variant widening).
- **No reranker model** → search returns fusion-ranked
  results without cross-encoder reranking.
- **No FTS index** (first search before any build completes)
  → error with guidance to run `rbtr watch`.

## Development

Development requires Python 3.13, uv, Node.js 22.19 or later,
npm, and just.

```bash
git clone <repo-url>
cd rbtr
just setup    # uv sync + npm install
just check    # lint, typecheck, and every test suite
```

See [ARCHITECTURE.md][arch] for internals.

## License

MIT
