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.
- rbtr_lang_markdown/__init__.py +1 -0
- rbtr_lang_markdown/injections.scm +4 -0
- rbtr_lang_markdown/links.scm +1 -0
- rbtr_lang_markdown/plugin.py +260 -0
- rbtr_lang_markdown/py.typed +0 -0
- rbtr_lang_markdown/sections.scm +3 -0
- rbtr_lang_markdown/tests/__init__.py +0 -0
- rbtr_lang_markdown/tests/__snapshots__/test_samples/test_edges_match_snapshot.json +5 -0
- rbtr_lang_markdown/tests/__snapshots__/test_samples/test_extraction_matches_snapshot.json +452 -0
- rbtr_lang_markdown/tests/cases_extraction.py +82 -0
- rbtr_lang_markdown/tests/samples/markdown/api.md +23 -0
- rbtr_lang_markdown/tests/samples/markdown/config.md +3 -0
- rbtr_lang_markdown/tests/samples/markdown/embedding.md +59 -0
- rbtr_lang_markdown/tests/samples/markdown/locales.md +3 -0
- rbtr_lang_markdown/tests/samples/markdown/markdown.md +16 -0
- rbtr_lang_markdown/tests/test_extraction.py +201 -0
- rbtr_lang_markdown/tests/test_samples.py +73 -0
- rbtr_lang_markdown-2026.7.0.dev0.dist-info/METADATA +8 -0
- rbtr_lang_markdown-2026.7.0.dev0.dist-info/RECORD +21 -0
- rbtr_lang_markdown-2026.7.0.dev0.dist-info/WHEEL +4 -0
- rbtr_lang_markdown-2026.7.0.dev0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Markdown language plugin package."""
|
|
@@ -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
|
|
File without changes
|
|
@@ -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,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,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,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,,
|