Metadata-Version: 2.4
Name: rbtr
Version: 2026.9.0.dev1
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 index                   # build the index
rbtr search "retry logic"    # search it
```

A background daemon starts automatically and keeps every
watched ref current (`HEAD` by default; add more with
`rbtr index <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 index
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 index`

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 index                    # watch HEAD (the default)
rbtr index main               # watch main, even from another branch
rbtr index main release       # watch several refs independently
rbtr index --remove main      # stop watching main (HEAD can't be removed)
rbtr index --remove            # forget this repo (only when HEAD is all it watches)
rbtr index --remove-stale-refs # stop watching this repo's deleted branches
rbtr index --remove-stale-repos # forget every repo whose checkout is gone
```

A moving ref (branch) tracks its tip; a bare SHA settles
after one build. Removing a ref stops watching it; its index
is reclaimed by `rbtr gc --watched-only` (a plain `rbtr gc`
keeps every branch/tag regardless).

When you're done with a checkout, `rbtr index --remove` (with no
refs) forgets the whole repo — its watch set, indexed commits, and
references. After you've already deleted a worktree or clone, run
`rbtr index --remove-stale-repos` from anywhere to forget every repo
whose path no longer exists. Forgetting is metadata-only and reports
no statistics; run `rbtr gc` to reclaim the freed chunks.

### `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 index` 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 (default: keep branches/tags + watch set)
rbtr gc --all-repos           # every indexed repo (default reclamation only)
rbtr gc --watched-only        # keep only HEAD and watched refs
rbtr gc --keep-head-only      # keep only HEAD
rbtr gc main release          # keep only HEAD plus these refs
rbtr gc --orphans             # 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. `--all-repos` reclaims
across **every** indexed repo at once — useful because chunks are shared
between repos — but only with the safe default reclamation; scope an
aggressive mode (`--watched-only`, `--keep-head-only`, or a `keep`
list) to a single repo. (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. `--watched-only` keeps
just HEAD and the watch set, dropping unwatched branches and
tags (the way to reclaim refs you no longer index).

The other modes: `--keep-head-only` keeps only HEAD; `rbtr gc <refs>`
keeps HEAD plus the listed refs; `--orphans` sweeps residue
from crashed builds.

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 (`--watched-only`).

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 index`.

## Development

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

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

## License

MIT
