rbtr-lang-markdown 2026.7.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Markdown language plugin package."""
@@ -0,0 +1,4 @@
1
+ ; Fenced code blocks: the info string names the embedded language.
2
+ (fenced_code_block
3
+ (info_string (language) @injection.language)
4
+ (code_fence_content) @injection.content)
@@ -0,0 +1 @@
1
+ (inline_link (link_destination) @dest)
@@ -0,0 +1,260 @@
1
+ """Markdown language plugin.
2
+
3
+ Splits Markdown by heading hierarchy using tree-sitter. Headed
4
+ sections produce one `doc_section` chunk per heading, containing
5
+ the heading and its direct content (excluding nested subsections).
6
+ Headingless documents fall back to `chunk_plaintext`.
7
+
8
+ Extracted chunks::
9
+
10
+ # Title → doc_section "Title", scope ""
11
+ Intro text. (content: heading + intro)
12
+
13
+ ## Section A → doc_section "Section A", scope "Title"
14
+ Body A. (content: heading + body, excludes
15
+ child sections)
16
+
17
+ First paragraph. → raw_chunk (plaintext fallback)
18
+ Second paragraph. → raw_chunk
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterator
24
+ from typing import TYPE_CHECKING
25
+
26
+ from tree_sitter import Language, Parser, Query, QueryCursor
27
+
28
+ from rbtr.index.identity import make_chunk_id
29
+ from rbtr.index.models import Chunk, ChunkKind, ImportMeta
30
+ from rbtr.languages.chunks import chunk_plaintext
31
+ from rbtr.languages.registration import LanguageRegistration, load_query
32
+
33
+ if TYPE_CHECKING:
34
+ from tree_sitter import Node, Range
35
+
36
+ # URL schemes that indicate external links (not cross-references).
37
+ _EXTERNAL_SCHEMES = ("http://", "https://", "mailto:", "ftp://")
38
+
39
+ # Block-level query: captures headed sections.
40
+ _SECTION_QUERY = load_query(__package__, "sections")
41
+
42
+ # Injection query: a fenced code block delegates its content to the language
43
+ # named in the fence's info string (```python, ```sh). The info-string name
44
+ # is captured dynamically and resolved to a language id by the runner.
45
+
46
+
47
+ # ── Chunking ─────────────────────────────────────────────────────────
48
+
49
+
50
+ def _has_headings(root: Node) -> bool:
51
+ """Return True if any section in the tree has an atx_heading."""
52
+ for child in root.children:
53
+ if child.type == "section":
54
+ if any(gc.type == "atx_heading" for gc in child.children):
55
+ return True
56
+ if _has_headings(child):
57
+ return True
58
+ return False
59
+
60
+
61
+ def _section_depth(node: Node) -> int:
62
+ """Count ancestor nodes of the same type.
63
+
64
+ In the tree-sitter markdown grammar, sections nest:
65
+ a `## Sub` section is a child `section` node of the
66
+ `# Top` section. The depth (number of same-typed
67
+ ancestors) maps directly to the heading level minus
68
+ one: depth 0 = `#`, depth 1 = `##`, etc.
69
+ """
70
+ depth = 0
71
+ ancestor = node.parent
72
+ while ancestor is not None:
73
+ if ancestor.type == node.type:
74
+ depth += 1
75
+ ancestor = ancestor.parent
76
+ return depth
77
+
78
+
79
+ def _section_own_content(node: Node, content_bytes: bytes) -> str:
80
+ """Return the section's own text, excluding nested subsections.
81
+
82
+ A parent section's byte span covers its children. Trimming
83
+ at the first child of the same type gives us only the
84
+ heading and the prose that belongs directly to this section.
85
+ """
86
+ end_byte = node.end_byte
87
+ for child in node.children:
88
+ if child.type == node.type:
89
+ end_byte = child.start_byte
90
+ break
91
+ return content_bytes[node.start_byte : end_byte].decode("utf-8", errors="replace").strip()
92
+
93
+
94
+ def _extract_sections(
95
+ content_bytes: bytes,
96
+ grammar: Language,
97
+ file_path: str,
98
+ blob_sha: str,
99
+ ranges: list[Range] | None,
100
+ ) -> Iterator[Chunk]:
101
+ """Run the section query and yield one chunk per headed section.
102
+
103
+ The tree-sitter markdown grammar represents headings as
104
+ nested `section` nodes. This function queries for all
105
+ sections that contain an `atx_heading` and produces one
106
+ `doc_section` chunk per match.
107
+
108
+ **Scope** is the parent heading chain, built by maintaining
109
+ a stack of heading names indexed by depth. When a section
110
+ at depth *d* is encountered, the stack is truncated to *d*
111
+ entries (popping deeper headings from a previous branch),
112
+ the current scope is read from the remaining stack, and
113
+ the new heading is pushed.
114
+
115
+ **Content** is trimmed to exclude nested subsections — see
116
+ `_section_own_content`.
117
+ """
118
+ query = Query(grammar, _SECTION_QUERY)
119
+ parser = Parser(grammar)
120
+ if ranges is not None:
121
+ parser.included_ranges = ranges
122
+ tree = parser.parse(content_bytes)
123
+ matches = QueryCursor(query).matches(tree.root_node)
124
+
125
+ # Heading names indexed by depth. Tracks the current
126
+ # branch of the heading tree so we can reconstruct the
127
+ # scope ("Top::Mid") for each section.
128
+ scope_stack: list[str] = []
129
+
130
+ for _pattern_idx, capture_dict in matches:
131
+ section_nodes = capture_dict.get("doc_section", [])
132
+ name_nodes = capture_dict.get("_section_name", [])
133
+ if not section_nodes or not name_nodes:
134
+ continue
135
+
136
+ node = section_nodes[0]
137
+ name = name_nodes[0].text.decode("utf-8", errors="replace") if name_nodes[0].text else ""
138
+
139
+ # Maintain the scope stack: pop back to this section's
140
+ # depth, read scope, then push the current heading.
141
+ depth = _section_depth(node)
142
+ while len(scope_stack) > depth:
143
+ scope_stack.pop()
144
+ scope_segments = list(scope_stack)
145
+ scope_stack.append(name)
146
+
147
+ text = _section_own_content(node, content_bytes)
148
+ if not text:
149
+ continue
150
+
151
+ line_start = node.start_point[0] + 1
152
+ yield Chunk.model_validate(
153
+ {
154
+ "blob_sha": blob_sha,
155
+ "file_path": file_path,
156
+ "kind": ChunkKind.DOC_SECTION,
157
+ "name": name,
158
+ "scope": scope_segments,
159
+ "language": "markdown",
160
+ "content": text,
161
+ "line_start": line_start,
162
+ "line_end": node.end_point[0] + 1,
163
+ }
164
+ )
165
+
166
+
167
+ def chunk_markdown(
168
+ file_path: str,
169
+ blob_sha: str,
170
+ content: str,
171
+ grammar: Language,
172
+ ranges: list[Range] | None = None,
173
+ ) -> Iterator[Chunk]:
174
+ """Split Markdown by heading hierarchy using tree-sitter."""
175
+ if not content.strip():
176
+ return
177
+
178
+ content_bytes = content.encode("utf-8")
179
+ parser = Parser(grammar)
180
+ if ranges is not None:
181
+ parser.included_ranges = ranges
182
+ tree = parser.parse(content_bytes)
183
+
184
+ if _has_headings(tree.root_node):
185
+ yield from _extract_sections(content_bytes, grammar, file_path, blob_sha, ranges)
186
+ else:
187
+ yield from chunk_plaintext(file_path, blob_sha, content)
188
+
189
+ # Extract local links as IMPORT chunks using the inline parser.
190
+ yield from _extract_links(content_bytes, file_path, blob_sha, ranges)
191
+
192
+
193
+ # ── Link extraction ──────────────────────────────────────────────────
194
+
195
+ _LINK_QUERY = load_query(__package__, "links")
196
+
197
+
198
+ def _extract_links(
199
+ content_bytes: bytes,
200
+ file_path: str,
201
+ blob_sha: str,
202
+ ranges: list[Range] | None,
203
+ ) -> Iterator[Chunk]:
204
+ """Extract local links as IMPORT chunks using the inline parser.
205
+
206
+ Parses the full content with `tree_sitter_markdown.inline_language()`
207
+ and queries for `inline_link` nodes. External URLs and same-file
208
+ fragment-only links are skipped.
209
+ """
210
+ import tree_sitter_markdown # deferred: heavy native lib
211
+
212
+ inline_lang = Language(tree_sitter_markdown.inline_language())
213
+ inline_parser = Parser(inline_lang)
214
+ if ranges is not None:
215
+ inline_parser.included_ranges = ranges
216
+ inline_tree = inline_parser.parse(content_bytes)
217
+
218
+ query = Query(inline_lang, _LINK_QUERY)
219
+ for _pattern_idx, captures in QueryCursor(query).matches(inline_tree.root_node):
220
+ for dest_node in captures.get("dest", []):
221
+ if dest_node.text is None:
222
+ continue
223
+ dest = dest_node.text.decode("utf-8", errors="replace")
224
+
225
+ # Skip external URLs and fragment-only anchors.
226
+ if any(dest.startswith(s) for s in _EXTERNAL_SCHEMES) or dest.startswith("#"):
227
+ continue
228
+
229
+ # Split path#fragment.
230
+ module = dest
231
+ names = ""
232
+ if "#" in dest:
233
+ module, names = dest.rsplit("#", 1)
234
+
235
+ yield Chunk(
236
+ id=make_chunk_id(file_path, blob_sha, f"link:{dest}", dest_node.start_point[0]),
237
+ blob_sha=blob_sha,
238
+ file_path=file_path,
239
+ kind=ChunkKind.IMPORT,
240
+ name=dest,
241
+ scope="",
242
+ content=dest,
243
+ metadata=ImportMeta(module=module, names=names),
244
+ line_start=dest_node.start_point[0] + 1,
245
+ line_end=dest_node.end_point[0] + 1,
246
+ )
247
+
248
+
249
+ # ── Plugin ───────────────────────────────────────────────────────────
250
+
251
+
252
+ markdown = LanguageRegistration(
253
+ id="markdown",
254
+ extensions=frozenset({".md"}),
255
+ grammar_module="tree_sitter_markdown",
256
+ injection_query=load_query(__package__, "injections"),
257
+ extraction_serial=4,
258
+ )
259
+
260
+ markdown.chunker(chunk_markdown)
File without changes
@@ -0,0 +1,3 @@
1
+ (section
2
+ (atx_heading
3
+ (inline) @_section_name)) @doc_section
File without changes
@@ -0,0 +1,5 @@
1
+ [
2
+ "markdown.md::api.md#format-greeting -> api.md::format-greeting [documents]",
3
+ "markdown.md::config.md -> config.md::Configuration [documents]",
4
+ "markdown.md::locales.md -> locales.md::Locales [documents]"
5
+ ]
@@ -0,0 +1,452 @@
1
+ [
2
+ {
3
+ "id": "4c81fa35133b40f2",
4
+ "blob_sha": "sha1",
5
+ "file_path": "api.md",
6
+ "kind": "doc_section",
7
+ "name": "API",
8
+ "scope": "",
9
+ "language": "markdown",
10
+ "content": "# API",
11
+ "line_start": 1,
12
+ "line_end": 24,
13
+ "metadata": {
14
+ "module": "",
15
+ "names": "",
16
+ "dots": "",
17
+ "language_hint": ""
18
+ }
19
+ },
20
+ {
21
+ "id": "31926f465277bc3a",
22
+ "blob_sha": "sha1",
23
+ "file_path": "api.md",
24
+ "kind": "doc_section",
25
+ "name": "format-greeting",
26
+ "scope": "API",
27
+ "language": "markdown",
28
+ "content": "## format-greeting\n\nFormats a greeting for a name.\n\nCall it from Python:\n\n```python\nfrom greeter import format_greeting\n\n\ndef demo() -> str:\n return format_greeting(\"Ada\")\n```\n\nOr wrap it in a shell helper:\n\n```sh\ngreet() {\n format-greeting \"$1\"\n}\n```",
29
+ "line_start": 3,
30
+ "line_end": 24,
31
+ "metadata": {
32
+ "module": "",
33
+ "names": "",
34
+ "dots": "",
35
+ "language_hint": ""
36
+ }
37
+ },
38
+ {
39
+ "id": "a506206919841452",
40
+ "blob_sha": "sha1",
41
+ "file_path": "api.md",
42
+ "kind": "import",
43
+ "name": "from greeter import format_greeting",
44
+ "scope": "",
45
+ "language": "python",
46
+ "content": "from greeter import format_greeting",
47
+ "line_start": 10,
48
+ "line_end": 10,
49
+ "metadata": {
50
+ "module": "greeter",
51
+ "names": "format_greeting",
52
+ "dots": "",
53
+ "language_hint": ""
54
+ }
55
+ },
56
+ {
57
+ "id": "67279506d1545bb8",
58
+ "blob_sha": "sha1",
59
+ "file_path": "api.md",
60
+ "kind": "function",
61
+ "name": "demo",
62
+ "scope": "",
63
+ "language": "python",
64
+ "content": "def demo() -> str:\n return format_greeting(\"Ada\")",
65
+ "line_start": 13,
66
+ "line_end": 14,
67
+ "metadata": {
68
+ "module": "",
69
+ "names": "",
70
+ "dots": "",
71
+ "language_hint": ""
72
+ }
73
+ },
74
+ {
75
+ "id": "7a4703203f71b0cf",
76
+ "blob_sha": "sha1",
77
+ "file_path": "api.md",
78
+ "kind": "function",
79
+ "name": "greet",
80
+ "scope": "",
81
+ "language": "bash",
82
+ "content": "greet() {\n format-greeting \"$1\"\n}",
83
+ "line_start": 20,
84
+ "line_end": 22,
85
+ "metadata": {
86
+ "module": "",
87
+ "names": "",
88
+ "dots": "",
89
+ "language_hint": ""
90
+ }
91
+ },
92
+ {
93
+ "id": "3ad1924164e7ff92",
94
+ "blob_sha": "sha1",
95
+ "file_path": "config.md",
96
+ "kind": "doc_section",
97
+ "name": "Configuration",
98
+ "scope": "",
99
+ "language": "markdown",
100
+ "content": "# Configuration\n\nGreeter configuration options.",
101
+ "line_start": 1,
102
+ "line_end": 4,
103
+ "metadata": {
104
+ "module": "",
105
+ "names": "",
106
+ "dots": "",
107
+ "language_hint": ""
108
+ }
109
+ },
110
+ {
111
+ "id": "a07c6280b6b72cff",
112
+ "blob_sha": "sha1",
113
+ "file_path": "embedding.md",
114
+ "kind": "doc_section",
115
+ "name": "Embedding",
116
+ "scope": "",
117
+ "language": "markdown",
118
+ "content": "# Embedding\n\nExamples embedded in the docs, each extracted in its own language —\nincluding code nested two levels deep.\n\nThe greeter config, in YAML:\n\n```yaml\nservice: greeter\nlocales:\n - en\n - fr\n```\n\nThe same in TOML:\n\n```toml\n[service]\nname = \"greeter\"\n```\n\nEmbedded in a page — the stylesheet is an import and the inline script is\nextracted as JavaScript:\n\n```html\n<head>\n <link rel=\"stylesheet\" href=\"greeter.css\">\n</head>\n<body>\n <main>\n <script>\n function mount() {\n return greet(\"Ada\");\n }\n </script>\n </main>\n</body>\n```\n\nAnd a component — Markdown delegates to the Svelte chunker, which in turn\ndelegates its `<script>` to TypeScript and its `<style>` to SCSS:\n\n```svelte\n<script lang=\"ts\">\n export let name: string;\n\n function shout(): string {\n return name.toUpperCase();\n }\n</script>\n\n<h1>Hello {name}</h1>\n\n<style lang=\"scss\">\n h1 {\n color: $brand;\n }\n</style>\n```",
119
+ "line_start": 1,
120
+ "line_end": 60,
121
+ "metadata": {
122
+ "module": "",
123
+ "names": "",
124
+ "dots": "",
125
+ "language_hint": ""
126
+ }
127
+ },
128
+ {
129
+ "id": "f7b93e9d583f37fb",
130
+ "blob_sha": "sha1",
131
+ "file_path": "embedding.md",
132
+ "kind": "config_key",
133
+ "name": "service",
134
+ "scope": "",
135
+ "language": "yaml",
136
+ "content": "service: greeter",
137
+ "line_start": 9,
138
+ "line_end": 9,
139
+ "metadata": {
140
+ "module": "",
141
+ "names": "",
142
+ "dots": "",
143
+ "language_hint": ""
144
+ }
145
+ },
146
+ {
147
+ "id": "ee32c29d4bb258d2",
148
+ "blob_sha": "sha1",
149
+ "file_path": "embedding.md",
150
+ "kind": "config_key",
151
+ "name": "locales",
152
+ "scope": "",
153
+ "language": "yaml",
154
+ "content": "locales:\n - en\n - fr\n",
155
+ "line_start": 10,
156
+ "line_end": 13,
157
+ "metadata": {
158
+ "module": "",
159
+ "names": "",
160
+ "dots": "",
161
+ "language_hint": ""
162
+ }
163
+ },
164
+ {
165
+ "id": "720abd335b592da1",
166
+ "blob_sha": "sha1",
167
+ "file_path": "embedding.md",
168
+ "kind": "config_key",
169
+ "name": "service",
170
+ "scope": "",
171
+ "language": "toml",
172
+ "content": "[service]\nname = \"greeter\"\n",
173
+ "line_start": 18,
174
+ "line_end": 20,
175
+ "metadata": {
176
+ "module": "",
177
+ "names": "",
178
+ "dots": "",
179
+ "language_hint": ""
180
+ }
181
+ },
182
+ {
183
+ "id": "dde01fdcfbaf35fe",
184
+ "blob_sha": "sha1",
185
+ "file_path": "embedding.md",
186
+ "kind": "doc_section",
187
+ "name": "head",
188
+ "scope": "",
189
+ "language": "html",
190
+ "content": "<head>\n <link rel=\"stylesheet\" href=\"greeter.css\">\n</head>",
191
+ "line_start": 26,
192
+ "line_end": 28,
193
+ "metadata": {
194
+ "module": "",
195
+ "names": "",
196
+ "dots": "",
197
+ "language_hint": ""
198
+ }
199
+ },
200
+ {
201
+ "id": "237cf8cd884f0889",
202
+ "blob_sha": "sha1",
203
+ "file_path": "embedding.md",
204
+ "kind": "import",
205
+ "name": "<link rel=\"stylesheet\" href=\"greeter.css\">",
206
+ "scope": "",
207
+ "language": "html",
208
+ "content": "<link rel=\"stylesheet\" href=\"greeter.css\">\n",
209
+ "line_start": 27,
210
+ "line_end": 28,
211
+ "metadata": {
212
+ "module": "greeter.css",
213
+ "names": "",
214
+ "dots": "",
215
+ "language_hint": "css"
216
+ }
217
+ },
218
+ {
219
+ "id": "648732774bcd70e1",
220
+ "blob_sha": "sha1",
221
+ "file_path": "embedding.md",
222
+ "kind": "doc_section",
223
+ "name": "body",
224
+ "scope": "",
225
+ "language": "html",
226
+ "content": "<body>\n <main>\n <script>\n function mount() {\n return greet(\"Ada\");\n }\n </script>\n </main>\n</body>",
227
+ "line_start": 29,
228
+ "line_end": 37,
229
+ "metadata": {
230
+ "module": "",
231
+ "names": "",
232
+ "dots": "",
233
+ "language_hint": ""
234
+ }
235
+ },
236
+ {
237
+ "id": "d5a2a0b511006ed9",
238
+ "blob_sha": "sha1",
239
+ "file_path": "embedding.md",
240
+ "kind": "doc_section",
241
+ "name": "main",
242
+ "scope": "",
243
+ "language": "html",
244
+ "content": "<main>\n <script>\n function mount() {\n return greet(\"Ada\");\n }\n </script>\n </main>",
245
+ "line_start": 30,
246
+ "line_end": 36,
247
+ "metadata": {
248
+ "module": "",
249
+ "names": "",
250
+ "dots": "",
251
+ "language_hint": ""
252
+ }
253
+ },
254
+ {
255
+ "id": "3c90b98fdfca2398",
256
+ "blob_sha": "sha1",
257
+ "file_path": "embedding.md",
258
+ "kind": "function",
259
+ "name": "mount",
260
+ "scope": "",
261
+ "language": "javascript",
262
+ "content": "function mount() {\n return greet(\"Ada\");\n }",
263
+ "line_start": 32,
264
+ "line_end": 34,
265
+ "metadata": {
266
+ "module": "",
267
+ "names": "",
268
+ "dots": "",
269
+ "language_hint": ""
270
+ }
271
+ },
272
+ {
273
+ "id": "fa22ee7555d55068",
274
+ "blob_sha": "sha1",
275
+ "file_path": "embedding.md",
276
+ "kind": "doc_section",
277
+ "name": "embedding",
278
+ "scope": "",
279
+ "language": "svelte",
280
+ "content": "<h1>Hello {name}</h1>",
281
+ "line_start": 52,
282
+ "line_end": 52,
283
+ "metadata": {
284
+ "module": "",
285
+ "names": "",
286
+ "dots": "",
287
+ "language_hint": ""
288
+ }
289
+ },
290
+ {
291
+ "id": "4447dc40f1fe4853",
292
+ "blob_sha": "sha1",
293
+ "file_path": "embedding.md",
294
+ "kind": "function",
295
+ "name": "shout",
296
+ "scope": "",
297
+ "language": "typescript",
298
+ "content": "function shout(): string {\n return name.toUpperCase();\n }",
299
+ "line_start": 47,
300
+ "line_end": 49,
301
+ "metadata": {
302
+ "module": "",
303
+ "names": "",
304
+ "dots": "",
305
+ "language_hint": ""
306
+ }
307
+ },
308
+ {
309
+ "id": "ff44cea1c6365f3c",
310
+ "blob_sha": "sha1",
311
+ "file_path": "embedding.md",
312
+ "kind": "class",
313
+ "name": "h1",
314
+ "scope": "",
315
+ "language": "scss",
316
+ "content": "h1 {\n color: $brand;\n }",
317
+ "line_start": 55,
318
+ "line_end": 57,
319
+ "metadata": {
320
+ "module": "",
321
+ "names": "",
322
+ "dots": "",
323
+ "language_hint": ""
324
+ }
325
+ },
326
+ {
327
+ "id": "28a545073daa5946",
328
+ "blob_sha": "sha1",
329
+ "file_path": "locales.md",
330
+ "kind": "doc_section",
331
+ "name": "Locales",
332
+ "scope": "",
333
+ "language": "markdown",
334
+ "content": "# Locales\n\nSupported locales: en, fr.",
335
+ "line_start": 1,
336
+ "line_end": 4,
337
+ "metadata": {
338
+ "module": "",
339
+ "names": "",
340
+ "dots": "",
341
+ "language_hint": ""
342
+ }
343
+ },
344
+ {
345
+ "id": "e3e544e26089db67",
346
+ "blob_sha": "sha1",
347
+ "file_path": "markdown.md",
348
+ "kind": "doc_section",
349
+ "name": "Greeter",
350
+ "scope": "",
351
+ "language": "markdown",
352
+ "content": "# Greeter\n\nA small library for formatting greetings for named recipients.",
353
+ "line_start": 1,
354
+ "line_end": 17,
355
+ "metadata": {
356
+ "module": "",
357
+ "names": "",
358
+ "dots": "",
359
+ "language_hint": ""
360
+ }
361
+ },
362
+ {
363
+ "id": "0f8107d00ba18621",
364
+ "blob_sha": "sha1",
365
+ "file_path": "markdown.md",
366
+ "kind": "doc_section",
367
+ "name": "Usage",
368
+ "scope": "Greeter",
369
+ "language": "markdown",
370
+ "content": "## Usage\n\nConfigure it via [the config file](config.md), then call the\n[format helper](api.md#format-greeting).\n\nFor background, see [an external guide](https://example.com), which is\nnot treated as a cross-reference.",
371
+ "line_start": 5,
372
+ "line_end": 17,
373
+ "metadata": {
374
+ "module": "",
375
+ "names": "",
376
+ "dots": "",
377
+ "language_hint": ""
378
+ }
379
+ },
380
+ {
381
+ "id": "14b9d728e5d4cd68",
382
+ "blob_sha": "sha1",
383
+ "file_path": "markdown.md",
384
+ "kind": "doc_section",
385
+ "name": "Locales",
386
+ "scope": "Greeter::Usage",
387
+ "language": "markdown",
388
+ "content": "### Locales\n\nSupported locales are documented in [the locales table](locales.md).\nJump to [the usage section](#usage) for examples.",
389
+ "line_start": 13,
390
+ "line_end": 17,
391
+ "metadata": {
392
+ "module": "",
393
+ "names": "",
394
+ "dots": "",
395
+ "language_hint": ""
396
+ }
397
+ },
398
+ {
399
+ "id": "b12622bb4964947e",
400
+ "blob_sha": "sha1",
401
+ "file_path": "markdown.md",
402
+ "kind": "import",
403
+ "name": "config.md",
404
+ "scope": "",
405
+ "language": "markdown",
406
+ "content": "config.md",
407
+ "line_start": 7,
408
+ "line_end": 7,
409
+ "metadata": {
410
+ "module": "config.md",
411
+ "names": "",
412
+ "dots": "",
413
+ "language_hint": ""
414
+ }
415
+ },
416
+ {
417
+ "id": "4b065fa2fc20ac27",
418
+ "blob_sha": "sha1",
419
+ "file_path": "markdown.md",
420
+ "kind": "import",
421
+ "name": "api.md#format-greeting",
422
+ "scope": "",
423
+ "language": "markdown",
424
+ "content": "api.md#format-greeting",
425
+ "line_start": 8,
426
+ "line_end": 8,
427
+ "metadata": {
428
+ "module": "api.md",
429
+ "names": "format-greeting",
430
+ "dots": "",
431
+ "language_hint": ""
432
+ }
433
+ },
434
+ {
435
+ "id": "2b980c3ee2e79b0c",
436
+ "blob_sha": "sha1",
437
+ "file_path": "markdown.md",
438
+ "kind": "import",
439
+ "name": "locales.md",
440
+ "scope": "",
441
+ "language": "markdown",
442
+ "content": "locales.md",
443
+ "line_start": 15,
444
+ "line_end": 15,
445
+ "metadata": {
446
+ "module": "locales.md",
447
+ "names": "",
448
+ "dots": "",
449
+ "language_hint": ""
450
+ }
451
+ }
452
+ ]
@@ -0,0 +1,82 @@
1
+ """Markdown extraction test cases (heading hierarchy → doc sections)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pytest_cases import case
6
+
7
+ type SymbolCase = tuple[str, str, list[tuple[str, str, str]]]
8
+
9
+
10
+ @case(tags=["symbol"])
11
+ def case_md_splits_by_heading() -> SymbolCase:
12
+ """Markdown heading hierarchy."""
13
+ src = """\
14
+ # Title
15
+
16
+ Intro text.
17
+
18
+ ## Section A
19
+
20
+ Body A.
21
+
22
+ ## Section B
23
+
24
+ Body B.
25
+ """
26
+ return (
27
+ "markdown",
28
+ src,
29
+ [
30
+ ("doc_section", "Title", ""),
31
+ ("doc_section", "Section A", "Title"),
32
+ ("doc_section", "Section B", "Title"),
33
+ ],
34
+ )
35
+
36
+
37
+ @case(tags=["symbol"])
38
+ def case_md_scope_chain() -> SymbolCase:
39
+ """Nested heading scope."""
40
+ src = """\
41
+ # Top
42
+
43
+ ## Mid
44
+
45
+ ### Deep
46
+
47
+ Content here.
48
+ """
49
+ return "markdown", src, [("doc_section", "Deep", "Top::Mid")]
50
+
51
+
52
+ @case(tags=["symbol"])
53
+ def case_md_same_name_under_different_parents() -> SymbolCase:
54
+ """Same-named sections under different parents get distinct scopes.
55
+
56
+ Two `Overview` subsections — one under `A`, one under `B` — are
57
+ `(doc_section, Overview, A)` and `(doc_section, Overview, B)`. Without
58
+ the full heading path they would collide on identity; addressing
59
+ keeps them apart.
60
+ """
61
+ src = """\
62
+ # A
63
+
64
+ Alpha intro.
65
+
66
+ ## Overview
67
+
68
+ Alpha overview.
69
+
70
+ # B
71
+
72
+ Beta intro.
73
+
74
+ ## Overview
75
+
76
+ Beta overview.
77
+ """
78
+ return (
79
+ "markdown",
80
+ src,
81
+ [("doc_section", "Overview", "A"), ("doc_section", "Overview", "B")],
82
+ )
@@ -0,0 +1,23 @@
1
+ # API
2
+
3
+ ## format-greeting
4
+
5
+ Formats a greeting for a name.
6
+
7
+ Call it from Python:
8
+
9
+ ```python
10
+ from greeter import format_greeting
11
+
12
+
13
+ def demo() -> str:
14
+ return format_greeting("Ada")
15
+ ```
16
+
17
+ Or wrap it in a shell helper:
18
+
19
+ ```sh
20
+ greet() {
21
+ format-greeting "$1"
22
+ }
23
+ ```
@@ -0,0 +1,3 @@
1
+ # Configuration
2
+
3
+ Greeter configuration options.
@@ -0,0 +1,59 @@
1
+ # Embedding
2
+
3
+ Examples embedded in the docs, each extracted in its own language —
4
+ including code nested two levels deep.
5
+
6
+ The greeter config, in YAML:
7
+
8
+ ```yaml
9
+ service: greeter
10
+ locales:
11
+ - en
12
+ - fr
13
+ ```
14
+
15
+ The same in TOML:
16
+
17
+ ```toml
18
+ [service]
19
+ name = "greeter"
20
+ ```
21
+
22
+ Embedded in a page — the stylesheet is an import and the inline script is
23
+ extracted as JavaScript:
24
+
25
+ ```html
26
+ <head>
27
+ <link rel="stylesheet" href="greeter.css">
28
+ </head>
29
+ <body>
30
+ <main>
31
+ <script>
32
+ function mount() {
33
+ return greet("Ada");
34
+ }
35
+ </script>
36
+ </main>
37
+ </body>
38
+ ```
39
+
40
+ And a component — Markdown delegates to the Svelte chunker, which in turn
41
+ delegates its `<script>` to TypeScript and its `<style>` to SCSS:
42
+
43
+ ```svelte
44
+ <script lang="ts">
45
+ export let name: string;
46
+
47
+ function shout(): string {
48
+ return name.toUpperCase();
49
+ }
50
+ </script>
51
+
52
+ <h1>Hello {name}</h1>
53
+
54
+ <style lang="scss">
55
+ h1 {
56
+ color: $brand;
57
+ }
58
+ </style>
59
+ ```
@@ -0,0 +1,3 @@
1
+ # Locales
2
+
3
+ Supported locales: en, fr.
@@ -0,0 +1,16 @@
1
+ # Greeter
2
+
3
+ A small library for formatting greetings for named recipients.
4
+
5
+ ## Usage
6
+
7
+ Configure it via [the config file](config.md), then call the
8
+ [format helper](api.md#format-greeting).
9
+
10
+ For background, see [an external guide](https://example.com), which is
11
+ not treated as a cross-reference.
12
+
13
+ ### Locales
14
+
15
+ Supported locales are documented in [the locales table](locales.md).
16
+ Jump to [the usage section](#usage) for examples.
@@ -0,0 +1,201 @@
1
+ """Markdown extraction tests.
2
+
3
+ The symbol cases (`cases_extraction.py`) drive the shared heading-hierarchy
4
+ check; the functions below pin Markdown's chunker behaviour (section content,
5
+ headingless fallback), fenced-code injection/delegation, and link extraction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pytest_cases import parametrize_with_cases
11
+
12
+ from rbtr.git import FileEntry
13
+ from rbtr.index.models import ChunkKind
14
+ from rbtr.languages.extract import extract_file
15
+
16
+
17
+ @parametrize_with_cases("lang, source, expected", cases=".cases_extraction", has_tag="symbol")
18
+ def test_extracts_expected_symbols(lang: str, source: str, expected: list) -> None:
19
+ """Each expected (kind, name, scope) tuple appears in the output."""
20
+ chunks = extract_file(FileEntry("input", "sha1", source.encode()), lang)
21
+ symbols = [(c.kind, c.name, c.scope) for c in chunks]
22
+ for exp in expected:
23
+ assert exp in symbols, f"expected {exp} not found in {symbols}"
24
+
25
+
26
+ def test_md_subsection_excluded_from_parent_content() -> None:
27
+ """Markdown parent chunk excludes child section content."""
28
+ src = """\
29
+ # Parent
30
+
31
+ Parent body.
32
+
33
+ ## Child
34
+
35
+ Child body.
36
+ """
37
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
38
+ parent = next(c for c in chunks if c.name == "Parent")
39
+ assert "Child body" not in parent.content
40
+ assert "Parent body" in parent.content
41
+
42
+
43
+ def test_md_headingless_paragraphs() -> None:
44
+ """Markdown without headings falls back to plaintext chunking."""
45
+ src = """\
46
+ First paragraph.
47
+
48
+ Second paragraph.
49
+ """
50
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
51
+ assert len(chunks) >= 1
52
+ assert all(c.kind == ChunkKind.RAW_CHUNK for c in chunks)
53
+
54
+
55
+ def test_md_chunker_target_extracted() -> None:
56
+ """A chunker-based target (yaml) inside a Markdown fence extracts.
57
+
58
+ Query targets (python) already worked; this proves the delegate runs a
59
+ chunker plugin over the block too, at absolute line numbers.
60
+ """
61
+ src = """\
62
+ # Doc
63
+
64
+ ```yaml
65
+ service: greeter
66
+ ```
67
+ """
68
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
69
+ yaml_sections = [c for c in chunks if c.language == "yaml"]
70
+ assert [c.name for c in yaml_sections] == ["service"]
71
+ assert yaml_sections[0].line_start == 4
72
+
73
+
74
+ def test_md_nested_injection_extracts_inner_js() -> None:
75
+ """Delegation recurses: markdown -> html -> its inline js.
76
+
77
+ An HTML block whose HTML contains an inline `<script>` yields both the
78
+ html chunks and the js function, each at absolute line numbers.
79
+ """
80
+ src = """\
81
+ # Doc
82
+
83
+ ```html
84
+ <body>
85
+ <main>
86
+ <script>
87
+ function boot() {
88
+ return 1;
89
+ }
90
+ </script>
91
+ </main>
92
+ </body>
93
+ ```
94
+ """
95
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
96
+ assert "html" in {c.language for c in chunks}
97
+ js = [c for c in chunks if c.language == "javascript" and c.kind == ChunkKind.FUNCTION]
98
+ assert [c.name for c in js] == ["boot"]
99
+ assert js[0].line_start == 7
100
+
101
+
102
+ def test_md_unknown_fence_left_unparsed() -> None:
103
+ """A fence naming a language rbtr has no grammar for is not delegated."""
104
+ src = """\
105
+ # Doc
106
+
107
+ ```nonexistent
108
+ some content here
109
+ ```
110
+ """
111
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
112
+ assert {c.language for c in chunks} == {"markdown"}
113
+
114
+
115
+ def test_md_local_link_produces_import() -> None:
116
+ """Markdown [text](local.md) produces an IMPORT chunk."""
117
+ src = """\
118
+ # Guide
119
+
120
+ See [other doc](other.md) for details.
121
+ """
122
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
123
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
124
+ assert len(imports) == 1
125
+ assert imports[0].metadata.module == "other.md"
126
+
127
+
128
+ def test_md_link_with_fragment_sets_names() -> None:
129
+ """Markdown [text](path.md#section) sets module and names."""
130
+ src = """\
131
+ # Guide
132
+
133
+ See [API section](api.md#my-function) for the API.
134
+ """
135
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
136
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
137
+ assert len(imports) == 1
138
+ assert imports[0].metadata.module == "api.md"
139
+ assert imports[0].metadata.names == "my-function"
140
+
141
+
142
+ def test_md_relative_link_produces_import() -> None:
143
+ """Markdown [text](../src/foo.py) with relative path produces import."""
144
+ src = """\
145
+ # Docs
146
+
147
+ See [source](../src/foo.py) for details.
148
+ """
149
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
150
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
151
+ assert len(imports) == 1
152
+ assert imports[0].metadata.module == "../src/foo.py"
153
+
154
+
155
+ def test_md_external_link_skipped() -> None:
156
+ """Markdown links to external URLs produce no import chunk."""
157
+ src = """\
158
+ # Guide
159
+
160
+ See [example](https://example.com) and [mail](mailto:a@b.com).
161
+ """
162
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
163
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
164
+ assert imports == []
165
+
166
+
167
+ def test_md_fragment_only_link_skipped() -> None:
168
+ """Markdown #-only links (same-file anchors) produce no import."""
169
+ src = """\
170
+ # Guide
171
+
172
+ See [below](#details) for more.
173
+ """
174
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
175
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
176
+ assert imports == []
177
+
178
+
179
+ def test_md_multiple_links_in_one_section() -> None:
180
+ """Multiple links in a single section produce multiple imports."""
181
+ src = """\
182
+ # Guide
183
+
184
+ See [a](one.md) and [b](two.py) here.
185
+ """
186
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
187
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
188
+ modules = {c.metadata.module for c in imports}
189
+ assert modules == {"one.md", "two.py"}
190
+
191
+
192
+ def test_md_bare_mention_no_import() -> None:
193
+ """Prose mentioning a symbol name without a link produces no import."""
194
+ src = """\
195
+ # Guide
196
+
197
+ Call do_stuff to process the data.
198
+ """
199
+ chunks = extract_file(FileEntry("input", "sha1", src.encode()), "markdown")
200
+ imports = [c for c in chunks if c.kind == ChunkKind.IMPORT]
201
+ assert imports == []
@@ -0,0 +1,73 @@
1
+ """Markdown sample extraction: the `samples/markdown/` project through the real pipeline.
2
+
3
+ The sample embeds fenced code (python, sh, yaml, toml, html, svelte), delegated to
4
+ those plugins, and links between files, so the snapshot captures the doc sections,
5
+ injected chunks, and cross-file edges.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ import pytest
14
+ from tree_sitter import Parser
15
+
16
+ from rbtr.git import FileEntry
17
+ from rbtr.index.models import Chunk, ChunkKind, Edge
18
+ from rbtr.languages.edges import build_resolution_map, infer_import_edges
19
+ from rbtr.languages.extract import extract_file
20
+ from rbtr.languages.manager import get_manager
21
+ from rbtr.testing import render_edges
22
+
23
+ if TYPE_CHECKING:
24
+ from syrupy.assertion import SnapshotAssertion
25
+
26
+
27
+ @pytest.fixture
28
+ def project() -> list[tuple[str, str]]:
29
+ root = Path(__file__).parent / "samples" / "markdown"
30
+ return [
31
+ (str(p.relative_to(root)), p.read_text()) for p in sorted(root.rglob("*")) if p.is_file()
32
+ ]
33
+
34
+
35
+ @pytest.fixture
36
+ def chunks(project: list[tuple[str, str]]) -> list[Chunk]:
37
+ manager = get_manager()
38
+ out: list[Chunk] = []
39
+ for path, text in project:
40
+ lang = manager.detect_language(path) or "markdown"
41
+ out.extend(extract_file(FileEntry(path, "sha1", text.encode()), lang))
42
+ return out
43
+
44
+
45
+ @pytest.fixture
46
+ def edges(project: list[tuple[str, str]], chunks: list[Chunk]) -> list[Edge]:
47
+ manager = get_manager()
48
+ repo_files = {path for path, _ in project}
49
+ return infer_import_edges(chunks, repo_files, build_resolution_map(manager))
50
+
51
+
52
+ def test_emits_expected_kinds(chunks: list[Chunk]) -> None:
53
+ """The sample exercises Markdown's doc-section, import, and (injected) function chunks."""
54
+ kinds = {c.kind for c in chunks}
55
+ assert {ChunkKind.DOC_SECTION, ChunkKind.IMPORT, ChunkKind.FUNCTION} <= kinds
56
+
57
+
58
+ def test_parses_cleanly(project: list[tuple[str, str]]) -> None:
59
+ manager = get_manager()
60
+ for path, text in project:
61
+ grammar = manager.grammar(manager.detect_language(path) or "markdown")
62
+ assert grammar is not None
63
+ assert not Parser(grammar).parse(text.encode()).root_node.has_error, path
64
+
65
+
66
+ def test_extraction_matches_snapshot(chunks: list[Chunk], snapshot_json: SnapshotAssertion) -> None:
67
+ assert chunks == snapshot_json
68
+
69
+
70
+ def test_edges_match_snapshot(
71
+ chunks: list[Chunk], edges: list[Edge], snapshot_json: SnapshotAssertion
72
+ ) -> None:
73
+ assert render_edges(edges, chunks) == snapshot_json
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: rbtr-lang-markdown
3
+ Version: 2026.7.0.dev0
4
+ Summary: rbtr — Markdown language plugin
5
+ License-Expression: MIT
6
+ Requires-Dist: rbtr==2026.7.0.dev0
7
+ Requires-Dist: tree-sitter-markdown
8
+ Requires-Python: >=3.13
@@ -0,0 +1,21 @@
1
+ rbtr_lang_markdown/__init__.py,sha256=weuithB4aHCTq-wPNp_y5ZwjPC1yeou0xULcRyuUFj4,40
2
+ rbtr_lang_markdown/injections.scm,sha256=cLOGbMRML3f8Jzswc2GDBpa-1Gi_zNTxnVQjbRa2xH0,176
3
+ rbtr_lang_markdown/links.scm,sha256=XctZN0F1jK3R25cB3iibwwrToLuKCXkI2OwAUs0PBrc,38
4
+ rbtr_lang_markdown/plugin.py,sha256=RUSmeMZu3Q38SqTE5U1vpk5exyjrEWWg6B3u9hyvXm0,9281
5
+ rbtr_lang_markdown/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ rbtr_lang_markdown/sections.scm,sha256=wngkUdNvGv02wG9iAJ4covTbUsYzz4s_w-SsfesmcJU,67
7
+ rbtr_lang_markdown/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ rbtr_lang_markdown/tests/__snapshots__/test_samples/test_edges_match_snapshot.json,sha256=Ro7UeECd2OXfodBcTNQOV2f9LmYmqtoD0z0-K-j8mfs,215
9
+ rbtr_lang_markdown/tests/__snapshots__/test_samples/test_extraction_matches_snapshot.json,sha256=-MPiZRLoGpBgtJS6WZOChTA6CHTaZVxeRXyXVMYQICk,11583
10
+ rbtr_lang_markdown/tests/cases_extraction.py,sha256=JQzTn_6WW4SpJQ9YzSgMECso7uYrlulLj2wNxEI5Ej4,1491
11
+ rbtr_lang_markdown/tests/samples/markdown/api.md,sha256=2F5g7PQLcEdKPES47TQca-SVTP3pA31YlfqQkRK56OM,263
12
+ rbtr_lang_markdown/tests/samples/markdown/config.md,sha256=OTgOzfy6xqEd32UC_PTKXRuy1iJjPm6hV2ujJSDZFsc,48
13
+ rbtr_lang_markdown/tests/samples/markdown/embedding.md,sha256=96CmLLZTGmt0_J3ef2UkT-faQUWdwtstEKUGbzu0x7w,924
14
+ rbtr_lang_markdown/tests/samples/markdown/locales.md,sha256=ebMR__v2cXo2bloVrZTF5SJTnXQiuh2-pA7VlcmTh9o,38
15
+ rbtr_lang_markdown/tests/samples/markdown/markdown.md,sha256=--s11aEN37CH13zF9hUlf1wYd1yBK6rx8L52YgfIWLk,426
16
+ rbtr_lang_markdown/tests/test_extraction.py,sha256=Sm-sPwXYMb_KQnNEh9PVZtAVm3Evclove53EtT-7eCo,6172
17
+ rbtr_lang_markdown/tests/test_samples.py,sha256=Dz6tzRtxSjfzqAjgqFyMim1HgIiGlVhEPgMNX8IzXhk,2516
18
+ rbtr_lang_markdown-2026.7.0.dev0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
19
+ rbtr_lang_markdown-2026.7.0.dev0.dist-info/entry_points.txt,sha256=GMt2XVnJ4yjJuObRxAuF8dLkxUIi1TS_f0YuaN7eZ9I,64
20
+ rbtr_lang_markdown-2026.7.0.dev0.dist-info/METADATA,sha256=B-A33cFRCjtijD9wnHDqi_k__P7DQYzJMB_yeGh4Zwg,232
21
+ rbtr_lang_markdown-2026.7.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [rbtr.languages]
2
+ markdown = rbtr_lang_markdown.plugin:markdown
3
+