ast_parser.py python
2,779 lines 109.7 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """AST parsing and symbol extraction for the code domain plugin.
2
3 This module provides the :class:`LanguageAdapter` protocol and concrete
4 adapters for parsing source files into :type:`SymbolTree` structures.
5
6 Language support matrix
7 -----------------------
8 - **Python** (``*.py``, ``*.pyi``): Full AST-based extraction using the
9 stdlib :mod:`ast` module. Content IDs are hashes of normalized (unparsed)
10 AST text — insensitive to whitespace, comments, and formatting.
11 - **JavaScript / TypeScript** (``*.js``, ``*.jsx``, ``*.mjs``, ``*.cjs``,
12 ``*.ts``, ``*.tsx``): tree-sitter based. Async functions, arrow functions
13 bound to ``const``/``let``, and module-level variables are all extracted.
14 - **Go** (``*.go``): tree-sitter based. Method qualified names carry the
15 receiver type (e.g. ``Dog.Bark``). Package-level ``const``/``var`` included.
16 - **Rust** (``*.rs``): tree-sitter based. Functions inside ``impl`` blocks
17 are qualified with the implementing type (e.g. ``Dog.bark``). ``static``,
18 ``const``, type aliases, and ``mod`` declarations are extracted.
19 - **Java** (``*.java``), **C#** (``*.cs``): tree-sitter based.
20 - **C** (``*.c``, ``*.h``), **C++** (``*.cpp``, ``*.cc``, ``*.cxx``,
21 ``*.hpp``, ``*.hxx``): tree-sitter based. Structs and enums extracted.
22 - **Ruby** (``*.rb``), **Kotlin** (``*.kt``, ``*.kts``): tree-sitter based.
23 - **Swift** (``*.swift``): tree-sitter based; requires ``py-tree-sitter-swift``
24 (degrades to file-level tracking if the package is unavailable).
25 - **Markdown** (``*.md``, ``*.rst``, ``*.txt``): ATX headings extracted as
26 ``section`` symbols; requires ``tree-sitter-markdown``.
27 - **CSS** (``*.css``): rule-sets, keyframes, @media, @supports, and @layer
28 extracted; requires ``tree-sitter-css``.
29 - **SCSS** (``*.scss``): superset of CSS symbols plus ``$variables``,
30 ``@mixin``, and ``@function``; requires ``tree-sitter-scss``.
31 - **HTML** (``*.html``, ``*.htm``): semantic elements and id-bearing elements
32 extracted; requires ``tree-sitter-html``.
33 - **TOML** (``*.toml``): stdlib :mod:`tomllib` based (Python 3.11+, zero extra
34 deps). Tables (``[section]``) and array-of-tables entries
35 (``[[section]]``) become ``section`` symbols; scalar key-value pairs become
36 ``variable`` symbols. Content IDs are computed from canonical JSON (sorted
37 keys, ISO date strings) — insensitive to comments and key ordering.
38
39 Symbol addresses
40 ----------------
41 Every extracted symbol is stored in the :type:`SymbolTree` dict under a
42 stable *address* key of the form::
43
44 "<workspace-relative-posix-path>::<qualified-symbol-name>"
45
46 Nested symbols (class methods) use dotted qualified names::
47
48 "src/models.py::User.save"
49 "src/models.py::User.__init__"
50
51 Top-level symbols::
52
53 "src/utils.py::calculate_total"
54 "src/utils.py::import::pathlib"
55
56 Content IDs and rename / move detection
57 ----------------------------------------
58 Each :class:`SymbolRecord` carries three hashes:
59
60 ``content_id``
61 SHA-256 of the full normalized AST of the symbol (includes name,
62 signature, and body). Two symbols are "the same thing" when their
63 ``content_id`` matches — regardless of where in the repo they live.
64
65 ``body_hash``
66 SHA-256 of the normalized body statements only (excludes the ``def``
67 line). Used to detect *renames*: same body, different name.
68
69 ``signature_id``
70 SHA-256 of ``"name(args) -> return"``. Used to detect *implementation-
71 only changes*: signature unchanged, body changed.
72
73 Extending
74 ---------
75 Implement :class:`LanguageAdapter` and append an instance to
76 :data:`ADAPTERS`. The adapter is selected by the file's suffix, with the
77 first matching adapter taking priority.
78 """
79
80 from __future__ import annotations
81 from muse.core.validation import MAX_AST_BYTES
82
83 import ast
84 import datetime
85 import hashlib
86 import importlib
87 import json
88 import logging
89 import pathlib
90 import re
91 import sys
92 import tomllib
93 import types as _types
94 from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
95
96 if TYPE_CHECKING:
97 from tree_sitter import Language, Node, Parser, Query, QueryCursor
98
99 logger = logging.getLogger(__name__)
100
101 type _IntMap = dict[str, int]
102 type _KindMap = dict[str, SymbolKind]
103 type _TomlMapping = dict[str, _TomlValue]
104
105 # ---------------------------------------------------------------------------
106 # Symbol record types
107 # ---------------------------------------------------------------------------
108
109 SymbolKind = Literal[
110 "function",
111 "async_function",
112 "class",
113 "method",
114 "async_method",
115 "variable",
116 "import",
117 "section", # Markdown ATX/setext heading; HTML semantic element
118 "rule", # CSS rule-set, @keyframes, @media, @supports, @layer (keyed by selector/query)
119 "mixin", # SCSS @mixin — outputs CSS properties, can be @include'd
120 "interface", # TypeScript interface, Java interface, C# interface, Go interface type
121 "type_alias", # TypeScript `type Foo = …`, Go plain type alias (non-struct/interface)
122 "enum", # TypeScript/Java/C#/Rust/C/C++ enum
123 "struct", # Rust struct, C/C++ struct, C# struct, Go struct type
124 "trait", # Rust trait, Swift protocol
125 "namespace", # C++ namespace, TypeScript namespace/module
126 "module", # Ruby module, Rust mod
127 "object", # Kotlin singleton object declaration (`object Foo {}`)
128 ]
129
130
131 class SymbolRecord(TypedDict):
132 """Content-addressed record for a single named symbol in source code.
133
134 Hash dimensions (v2)
135 --------------------
136 ``content_id``
137 SHA-256 of the full normalized symbol (name + signature + body).
138 ``body_hash``
139 SHA-256 of body statements only. Same body, different name → rename.
140 ``signature_id``
141 SHA-256 of ``"name(args) -> return"``. Stable across body changes.
142 ``metadata_id``
143 SHA-256 of metadata that wraps the symbol but is not part of its body:
144 decorators, ``async`` flag, class bases, visibility modifiers (where
145 extractable by the language adapter). Empty string for legacy records
146 or adapters that do not support metadata extraction.
147 ``canonical_key``
148 Stable machine handle: ``{file}#{scope_path}#{kind}#{name}#{lineno}``.
149 Disambiguates overloads and nested scopes. Unique within a snapshot.
150 """
151
152 kind: SymbolKind
153 name: str
154 qualified_name: str # "ClassName.method" for nested; flat name for top-level
155 content_id: str # SHA-256 of full normalized AST (name + signature + body)
156 body_hash: str # SHA-256 of body stmts only — for rename detection
157 signature_id: str # SHA-256 of "name(args)->return" — for impl-only changes
158 metadata_id: str # SHA-256 of decorator/async/bases metadata (v2; "" = pre-v2)
159 canonical_key: str # {file}#{scope}#{kind}#{name}#{lineno} — stable handle
160 lineno: int
161 end_lineno: int
162
163
164 #: Flat map from symbol address to :class:`SymbolRecord`.
165 #: Nested symbols (methods) appear at their qualified address alongside the
166 #: parent class.
167 SymbolTree = dict[str, SymbolRecord]
168
169
170 # ---------------------------------------------------------------------------
171 # Language adapter protocol
172 # ---------------------------------------------------------------------------
173
174
175 @runtime_checkable
176 class LanguageAdapter(Protocol):
177 """Protocol every language adapter must implement.
178
179 Adapters are stateless. The same instance may be called concurrently
180 for different files without synchronization.
181 """
182
183 def supported_extensions(self) -> frozenset[str]:
184 """Return the set of lowercase file suffixes this adapter handles."""
185 ...
186
187 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
188 """Extract the symbol tree from raw source bytes.
189
190 Args:
191 source: Raw bytes of the source file.
192 file_path: Workspace-relative POSIX path — used to build the
193 symbol address prefix.
194
195 Returns:
196 A :type:`SymbolTree` mapping symbol addresses to
197 :class:`SymbolRecord` dicts. Returns an empty dict on parse
198 errors so that the caller can fall through to file-level ops.
199 """
200 ...
201
202 def file_content_id(self, source: bytes) -> str:
203 """Return a stable content identifier for the whole file.
204
205 For AST-capable adapters: hash of the normalized (unparsed) module
206 AST — insensitive to formatting and comments.
207 For non-AST adapters: SHA-256 of raw bytes.
208
209 Args:
210 source: Raw bytes of the file.
211
212 Returns:
213 Hex-encoded SHA-256 digest.
214 """
215 ...
216
217
218 # ---------------------------------------------------------------------------
219 # Helpers
220 # ---------------------------------------------------------------------------
221
222
223 def _sha256(text: str) -> str:
224 return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
225
226
227 def _sha256_bytes(data: bytes) -> str:
228 return hashlib.sha256(data).hexdigest()
229
230
231 # ---------------------------------------------------------------------------
232 # Python adapter
233 # ---------------------------------------------------------------------------
234
235
236 class PythonAdapter:
237 """Python language adapter — AST-based, zero external dependencies.
238
239 Uses :func:`ast.parse` for parsing and :func:`ast.unparse` for
240 normalization. The result is a deterministic, whitespace-insensitive
241 representation that strips comments and normalizes indentation.
242
243 ``ast.unparse`` is available since Python 3.9; Muse requires 3.12.
244 """
245
246 def supported_extensions(self) -> frozenset[str]:
247 return frozenset({".py", ".pyi"})
248
249 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
250 if len(source) > MAX_AST_BYTES:
251 logger.debug(
252 "ast_parser: skipping %s (%d bytes exceeds MAX_AST_BYTES=%d)",
253 file_path, len(source), MAX_AST_BYTES,
254 )
255 return {}
256 try:
257 tree = ast.parse(source, filename=file_path)
258 except SyntaxError:
259 return {}
260 symbols: SymbolTree = {}
261 _extract_stmts(tree.body, file_path, "", symbols)
262 return symbols
263
264 def file_content_id(self, source: bytes) -> str:
265 if len(source) > MAX_AST_BYTES:
266 return _sha256_bytes(source)
267 try:
268 tree = ast.parse(source)
269 return _sha256(ast.unparse(tree))
270 except SyntaxError:
271 return _sha256_bytes(source)
272
273
274 # ---------------------------------------------------------------------------
275 # AST extraction helpers (module-level so they can be tested independently)
276 # ---------------------------------------------------------------------------
277
278
279 def _extract_stmts(
280 stmts: list[ast.stmt],
281 file_path: str,
282 class_prefix: str,
283 out: SymbolTree,
284 ) -> None:
285 """Recursively walk *stmts* and populate *out* with symbol records.
286
287 Args:
288 stmts: Statement list from an :class:`ast.Module` or
289 :class:`ast.ClassDef` body.
290 file_path: Workspace-relative POSIX path — used as address prefix.
291 class_prefix: Dotted class path for methods (e.g. ``"MyClass."``).
292 Empty string at top-level.
293 out: Accumulator — modified in place.
294 """
295 for node in stmts:
296 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
297 is_async = isinstance(node, ast.AsyncFunctionDef)
298 if class_prefix:
299 kind: SymbolKind = "async_method" if is_async else "method"
300 else:
301 kind = "async_function" if is_async else "function"
302 qualified = f"{class_prefix}{node.name}"
303 addr = f"{file_path}::{qualified}"
304 out[addr] = _make_func_record(
305 node, node.name, qualified, kind,
306 file_path=file_path, class_prefix=class_prefix,
307 )
308
309 elif isinstance(node, ast.ClassDef):
310 qualified = f"{class_prefix}{node.name}"
311 addr = f"{file_path}::{qualified}"
312 out[addr] = _make_class_record(node, qualified, file_path=file_path)
313 _extract_stmts(node.body, file_path, f"{qualified}.", out)
314
315 elif isinstance(node, (ast.Assign, ast.AnnAssign)) and not class_prefix:
316 # Only top-level assignments — class-level attributes are captured
317 # as part of the parent class's content_id.
318 for name in _assignment_names(node):
319 addr = f"{file_path}::{name}"
320 out[addr] = _make_var_record(node, name, file_path=file_path)
321
322 elif isinstance(node, ast.TypeAlias) and not class_prefix:
323 # Python 3.12+ ``type X = ...`` soft-keyword statement.
324 # Treated as a named variable so the symbol appears in the graph
325 # and import-stale checks resolve correctly.
326 if isinstance(node.name, ast.Name):
327 name = node.name.id
328 addr = f"{file_path}::{name}"
329 out[addr] = _make_var_record(node, name, file_path=file_path)
330
331 elif isinstance(node, (ast.Import, ast.ImportFrom)) and not class_prefix:
332 for name, original in _import_names(node):
333 addr = f"{file_path}::import::{name}"
334 out[addr] = _make_import_record(
335 node, name, file_path=file_path, original_name=original
336 )
337
338
339 def _compute_metadata_id_func(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
340 """SHA-256 of Python function metadata: decorators + async flag."""
341 dec_src = " ".join(ast.unparse(d) for d in node.decorator_list)
342 async_flag = "async" if isinstance(node, ast.AsyncFunctionDef) else "sync"
343 return _sha256(f"{async_flag}:{dec_src}")
344
345
346 def _compute_metadata_id_class(node: ast.ClassDef) -> str:
347 """SHA-256 of Python class metadata: decorators + bases."""
348 dec_src = " ".join(ast.unparse(d) for d in node.decorator_list)
349 base_src = ", ".join(ast.unparse(b) for b in node.bases)
350 return _sha256(f"{dec_src}:{base_src}")
351
352
353 def _canonical_key(
354 file_path: str, scope: str, kind: str, name: str, lineno: int
355 ) -> str:
356 """Return the canonical machine handle for a symbol.
357
358 Format: ``{file}#{scope}#{kind}#{name}#{lineno}``
359
360 ``scope`` is the dotted class prefix (e.g. ``User.``) or empty for
361 top-level symbols. This key is unique within a snapshot and stable
362 across renames (by lineno), though lineno drift after edits is expected.
363 """
364 return f"{file_path}#{scope}#{kind}#{name}#{lineno}"
365
366
367 def _make_func_record(
368 node: ast.FunctionDef | ast.AsyncFunctionDef,
369 name: str,
370 qualified_name: str,
371 kind: SymbolKind,
372 file_path: str = "",
373 class_prefix: str = "",
374 ) -> SymbolRecord:
375 full_src = ast.unparse(node)
376 body_src = "\n".join(ast.unparse(s) for s in node.body)
377 args_src = ast.unparse(node.args)
378 ret_src = ast.unparse(node.returns) if node.returns else ""
379 return SymbolRecord(
380 kind=kind,
381 name=name,
382 qualified_name=qualified_name,
383 content_id=_sha256(full_src),
384 body_hash=_sha256(body_src),
385 signature_id=_sha256(f"{name}({args_src})->{ret_src}"),
386 metadata_id=_compute_metadata_id_func(node),
387 canonical_key=_canonical_key(file_path, class_prefix, kind, name, node.lineno),
388 lineno=node.lineno,
389 end_lineno=node.end_lineno or node.lineno,
390 )
391
392
393 def _make_class_record(
394 node: ast.ClassDef,
395 qualified_name: str,
396 file_path: str = "",
397 ) -> SymbolRecord:
398 full_src = ast.unparse(node)
399 base_src = ", ".join(ast.unparse(b) for b in node.bases) if node.bases else ""
400 # Body hash captures class structure (bases + method names) but NOT method
401 # bodies — those change independently and have their own records.
402 method_names = sorted(
403 n.name
404 for n in node.body
405 if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
406 )
407 structure = f"class {node.name}({base_src}):{method_names}"
408 header = f"class {node.name}({base_src})" if node.bases else f"class {node.name}"
409 return SymbolRecord(
410 kind="class",
411 name=node.name,
412 qualified_name=qualified_name,
413 content_id=_sha256(full_src),
414 body_hash=_sha256(structure),
415 signature_id=_sha256(header),
416 metadata_id=_compute_metadata_id_class(node),
417 canonical_key=_canonical_key(file_path, "", "class", node.name, node.lineno),
418 lineno=node.lineno,
419 end_lineno=node.end_lineno or node.lineno,
420 )
421
422
423 def _make_var_record(
424 node: ast.Assign | ast.AnnAssign | ast.TypeAlias,
425 name: str,
426 file_path: str = "",
427 ) -> SymbolRecord:
428 normalized = ast.unparse(node)
429 return SymbolRecord(
430 kind="variable",
431 name=name,
432 qualified_name=name,
433 content_id=_sha256(normalized),
434 body_hash=_sha256(normalized),
435 signature_id=_sha256(name),
436 metadata_id="",
437 canonical_key=_canonical_key(file_path, "", "variable", name, node.lineno),
438 lineno=node.lineno,
439 end_lineno=node.end_lineno or node.lineno,
440 )
441
442
443 def _make_import_record(
444 node: ast.Import | ast.ImportFrom,
445 name: str,
446 file_path: str = "",
447 original_name: str = "",
448 ) -> SymbolRecord:
449 normalized = ast.unparse(node)
450 # Encode the source module so downstream consumers (e.g. the breakage
451 # checker) can distinguish muse-internal imports from stdlib / third-party
452 # imports without re-parsing.
453 #
454 # Format:
455 # "import::<module>::<original>" — from <module> import <orig> [as <alias>]
456 # "import::<original>" — import <orig> [as <alias>]
457 #
458 # ``original_name`` is the name as written in the source (before any ``as``
459 # alias). ``name`` is the local alias (equal to original when there is no
460 # ``as`` clause). Storing the original in qualified_name lets the breakage
461 # checker resolve the real module path even when an alias is used.
462 orig = original_name or name
463 if isinstance(node, ast.ImportFrom) and node.module:
464 qualified = f"import::{node.module}::{orig}"
465 else:
466 qualified = f"import::{orig}"
467 return SymbolRecord(
468 kind="import",
469 name=name,
470 qualified_name=qualified,
471 content_id=_sha256(normalized),
472 body_hash=_sha256(normalized),
473 signature_id=_sha256(name),
474 metadata_id="",
475 canonical_key=_canonical_key(file_path, "", "import", name, node.lineno),
476 lineno=node.lineno,
477 end_lineno=node.lineno,
478 )
479
480
481 def _assignment_names(node: ast.Assign | ast.AnnAssign) -> list[str]:
482 if isinstance(node, ast.Assign):
483 return [t.id for t in node.targets if isinstance(t, ast.Name)]
484 if isinstance(node.target, ast.Name):
485 return [node.target.id]
486 return []
487
488
489 def _import_names(node: ast.Import | ast.ImportFrom) -> list[tuple[str, str]]:
490 """Return ``(alias, original)`` pairs for each name in the import statement.
491
492 ``alias`` is the local binding name (what the code uses after import).
493 ``original`` is the name as written in the source before any ``as`` clause.
494 When there is no alias, both are the same string.
495 """
496 if isinstance(node, ast.Import):
497 return [(a.asname or a.name, a.name) for a in node.names]
498 # ImportFrom
499 if node.names and node.names[0].name == "*":
500 wildcard = f"*:{node.module or '?'}"
501 return [(wildcard, wildcard)]
502 return [(a.asname or a.name, a.name) for a in node.names]
503
504
505 # ---------------------------------------------------------------------------
506 # Fallback adapter — file-level identity only, no symbol extraction
507 # ---------------------------------------------------------------------------
508
509
510 class FallbackAdapter:
511 """Fallback adapter for languages without a dedicated AST parser.
512
513 Returns an empty :type:`SymbolTree` (file-level tracking only) and uses
514 raw-bytes SHA-256 as the file content ID.
515 """
516
517 def __init__(self, extensions: frozenset[str]) -> None:
518 self._extensions = extensions
519
520 def supported_extensions(self) -> frozenset[str]:
521 return self._extensions
522
523 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree: # noqa: ARG002
524 return {}
525
526 def file_content_id(self, source: bytes) -> str:
527 return _sha256_bytes(source)
528
529
530 # ---------------------------------------------------------------------------
531 # Markdown adapter — ATX heading extraction via tree-sitter-markdown
532 # ---------------------------------------------------------------------------
533
534 # Maps ATX heading marker node types to their integer level (1–6).
535 _MD_HEADING_LEVEL: _IntMap = {
536 "atx_h1_marker": 1,
537 "atx_h2_marker": 2,
538 "atx_h3_marker": 3,
539 "atx_h4_marker": 4,
540 "atx_h5_marker": 5,
541 "atx_h6_marker": 6,
542 }
543
544 #: Maximum characters kept from heading text when building symbol addresses.
545 #: Guards against pathological documents with enormously long headings.
546 _MD_MAX_HEADING_LEN: int = 120
547
548 #: Compiled pattern that strips common inline Markdown markup from heading
549 #: text so that minor formatting changes (adding/removing bold markers) do
550 #: not alter the section's symbol address.
551 #:
552 #: Groups (in order): link text, reference-link text, bold/italic *-text,
553 #: bold/italic _-text, inline-code text. Images are consumed with no group
554 #: so they collapse to an empty string. Each text group is bounded to 200
555 #: characters to prevent catastrophic backtracking on adversarial input.
556 _MD_INLINE_RE: re.Pattern[str] = re.compile(
557 r"!\[[^\]]{0,200}\]\([^)]{0,500}\)" # ![alt](url) — drop
558 r"|!\[[^\]]{0,200}\]\[[^\]]{0,200}\]" # ![alt][ref] — drop
559 r"|\[([^\]]{0,200})\]\([^)]{0,500}\)" # [text](url) — keep text
560 r"|\[([^\]]{0,200})\]\[[^\]]{0,200}\]" # [text][ref] — keep text
561 r"|\*{1,3}([^*]{0,200})\*{1,3}" # *em* / **bold** — keep text
562 r"|_{1,3}([^_]{0,200})_{1,3}" # _em_ / __bold__ — keep text
563 r"|`+([^`]{0,200})`+" # `code` — keep text
564 )
565
566
567 def _plain_heading(raw: str) -> str:
568 """Reduce raw Markdown inline text to a plain, address-safe heading.
569
570 Strips image tags, extracts link text from ``[text](url)`` syntax,
571 and removes bold/italic/inline-code markers while keeping their inner
572 text. The result is whitespace-collapsed and truncated to
573 ``_MD_MAX_HEADING_LEN`` characters.
574
575 The output is used as the symbol ``name`` and as a component of the
576 ``qualified_name`` address, so it must be stable across minor
577 formatting changes (e.g. wrapping a word in bold markers).
578
579 Args:
580 raw: Raw inline text from an ``atx_heading`` ``inline`` child node.
581
582 Returns:
583 Plain-text heading, collapsed and truncated.
584 """
585 def _keep_text(m: re.Match[str]) -> str:
586 # Return the first non-None captured group (the visible text).
587 # Images have no group, so all groups are None → returns "".
588 for g in m.groups():
589 if g is not None:
590 return g
591 return ""
592
593 text = _MD_INLINE_RE.sub(_keep_text, raw)
594 # Decode the five most common HTML entities that appear in headings.
595 text = (
596 text.replace("&amp;", "&")
597 .replace("&lt;", "<")
598 .replace("&gt;", ">")
599 .replace("&quot;", '"')
600 .replace("&#39;", "'")
601 )
602 return " ".join(text.split())[: _MD_MAX_HEADING_LEN]
603
604
605 class MarkdownAdapter:
606 """Semantic symbol extraction from Markdown (GFM) source files.
607
608 Extracts three categories of independently addressable symbols from
609 Markdown documents, enabling section-level and element-level diff
610 precision far beyond file-level tracking:
611
612 **Sections** (``section`` kind)
613 Each ATX heading (``#``, ``##``, …) creates a ``section`` symbol.
614 Symbols are nested hierarchically so that ``## Installation``
615 under ``# API Reference`` gets the qualified name
616 ``API Reference.Installation``, preventing address collisions
617 between identically-named headings in different contexts.
618
619 ``content_id``
620 SHA-256 of the *full* section bytes — the heading line plus all
621 content (paragraphs, code blocks, tables, and nested subsections)
622 up to the next heading at the same or higher level. Two branches
623 that each modify a *different* section will have non-overlapping
624 ``content_id`` changes and auto-merge. Two branches that modify
625 the *same* section conflict at section granularity rather than
626 file granularity.
627
628 ``body_hash``
629 SHA-256 of content *below* the heading line only (bytes from the
630 end of the heading node to the end of the section node). A shared
631 ``body_hash`` with a different ``signature_id`` signals a heading
632 retitle — the section was renamed but its content is unchanged.
633
634 ``signature_id``
635 SHA-256 of ``"h{level}:{plain_text}"``. Encodes both the heading
636 level and the plain text, so promoting ``##`` to ``#`` is
637 independently detectable from content changes.
638
639 ``metadata_id``
640 SHA-256 of ``"level={N}"``. The level encoded separately so that
641 a level-only change (``###`` → ``##`` with identical text) produces
642 a ``metadata_id`` change but identical ``body_hash``.
643
644 **Fenced code blocks** (``variable`` kind)
645 Each ````` ```lang ... ``` ````` block is emitted as a ``variable``
646 symbol scoped to its containing section (or the document root when
647 not inside any section). The symbol name encodes the language tag
648 and start line: ``code[python]@L15``.
649
650 ``content_id`` / ``body_hash``
651 SHA-256 of the code fence *content* only (whitespace-normalised),
652 excluding delimiters and the language tag. Trivial reformatting
653 (trailing spaces, final newline) produces no diff.
654
655 ``signature_id``
656 SHA-256 of the language tag alone — stable across content changes,
657 signals a language-tag change (e.g. ``python`` → ``python3``).
658
659 **GFM pipe tables** (``section`` kind)
660 Each ``| col | col |`` table is emitted as a ``section`` symbol
661 scoped to its containing section. Name: ``table@L{start_line}``.
662
663 ``content_id``
664 SHA-256 of the full table bytes (header + delimiter + data rows).
665
666 ``body_hash``
667 SHA-256 of data rows only — adding a new column header is
668 detectable independently from adding a new data row.
669
670 ``signature_id``
671 SHA-256 of ``"|".join(column_names)`` — the table schema.
672
673 Heading address stability
674 -------------------------
675 Inline markup is stripped before building symbol addresses: bold,
676 italic, inline-code, and link markup are removed while keeping their
677 visible text. This makes the section address stable across formatting
678 changes (``# **Setup**`` and ``# Setup`` produce the same address).
679
680 Collision handling
681 ------------------
682 When two headings at the same nesting level share the same plain text,
683 the second heading gets ``@L{lineno}`` appended to its qualified name
684 (e.g. ``Examples@L42``). This is stable within a commit because line
685 numbers do not change unless the document is edited.
686
687 Scope and limitations
688 ---------------------
689 - Only ATX-style headings create section symbols. Setext headings
690 (underline-style) are not extracted.
691 - ``.rst`` and ``.txt`` extensions are registered for backward
692 compatibility; semantic extraction is only meaningful for ``.md``
693 files since RST uses different heading conventions.
694 - Requires ``tree-sitter-markdown``; degrades to an empty symbol tree
695 (file-level tracking only) when the grammar package is unavailable.
696 - Maximum section nesting depth is ``_MAX_DEPTH`` (default 8).
697 - Language tags in fenced code blocks are truncated to 40 characters
698 to prevent unbounded symbol names.
699 """
700
701 _EXTENSIONS: frozenset[str] = frozenset({".md", ".rst", ".txt"})
702 _MAX_DEPTH: int = 8
703
704 def __init__(self) -> None:
705 self._parser: Parser | None = None
706 try:
707 from tree_sitter import Language, Parser
708 import tree_sitter_markdown as _md
709 lang = Language(_md.language())
710 self._parser = Parser(lang)
711 except Exception as exc: # noqa: BLE001
712 logger.debug(
713 "tree-sitter-markdown unavailable — Markdown file-level only: %s", exc
714 )
715
716 def supported_extensions(self) -> frozenset[str]:
717 return self._EXTENSIONS
718
719 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
720 """Extract section, code-block, and table symbols from *source*.
721
722 When ``tree-sitter-markdown`` is available, a full CST walk extracts
723 sections, fenced code blocks, and GFM tables. When the grammar package
724 is absent the method falls back to a pure-Python ATX heading scan that
725 extracts ``section`` symbols from lines matching ``^#{1,6} text``. The
726 fallback produces the same address/hash scheme as the tree-sitter path
727 so that diffs are accurate whenever the optional dependency is missing.
728
729 Args:
730 source: Raw bytes of the Markdown file.
731 file_path: Workspace-relative POSIX path — used as address prefix.
732
733 Returns:
734 A :type:`SymbolTree` mapping qualified addresses to
735 :class:`SymbolRecord` dicts.
736 """
737 if self._parser is None:
738 return self._parse_headings_regex(source, file_path)
739 try:
740 tree = self._parser.parse(source)
741 except Exception as exc: # noqa: BLE001
742 logger.debug("Markdown parse error in %s: %s", file_path, exc)
743 return self._parse_headings_regex(source, file_path)
744 symbols: SymbolTree = {}
745 self._walk_children(tree.root_node, source, file_path, "", symbols, 0)
746 return symbols
747
748 # ATX heading pattern: optional leading spaces (up to 3), 1–6 '#', space, text.
749 _ATX_RE: re.Pattern[str] = re.compile(r"^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$")
750
751 def _parse_headings_regex(self, source: bytes, file_path: str) -> SymbolTree:
752 """Pure-Python ATX heading extraction — fallback when tree-sitter is absent.
753
754 Scans *source* line-by-line for ATX headings (``# Heading``) and emits
755 a ``section`` symbol for each one, using the same address and hash
756 scheme as the tree-sitter path. Fenced code blocks are skipped so that
757 ``#`` inside a code fence is not misidentified as a heading.
758
759 Args:
760 source: Raw bytes of the Markdown file.
761 file_path: Workspace-relative POSIX path — used as address prefix.
762
763 Returns:
764 A :type:`SymbolTree` with one ``section`` entry per ATX heading.
765 """
766 text = source.decode("utf-8", errors="replace")
767 lines = text.splitlines()
768 symbols: SymbolTree = {}
769
770 # Track heading stack to build hierarchical qualified names.
771 # Each slot: (level, qualified_name).
772 stack: list[tuple[int, str]] = []
773
774 in_fence = False
775 fence_marker = ""
776
777 # Collect (lineno, level, plain_text, section_start) tuples first so
778 # we can compute end_lineno from the next heading at the same/higher level.
779 heading_spans: list[tuple[int, int, str]] = []
780
781 for i, line in enumerate(lines, start=1):
782 stripped = line.strip()
783 # Detect fenced code block open/close.
784 if not in_fence:
785 if stripped.startswith("```") or stripped.startswith("~~~"):
786 in_fence = True
787 fence_marker = stripped[:3]
788 continue
789 else:
790 if stripped.startswith(fence_marker):
791 in_fence = False
792 continue
793
794 m = self._ATX_RE.match(line)
795 if m:
796 level = len(m.group(1))
797 plain_text = _plain_heading(m.group(2).strip())
798 if plain_text:
799 heading_spans.append((i, level, plain_text))
800
801 # Build symbols from collected spans.
802 for idx, (lineno, level, plain_text) in enumerate(heading_spans):
803 # Compute end_lineno: line before next heading at same/higher level,
804 # or end of file.
805 end_lineno = len(lines)
806 for j in range(idx + 1, len(heading_spans)):
807 if heading_spans[j][1] <= level:
808 end_lineno = heading_spans[j][0] - 1
809 break
810
811 # Build qualified name from the heading stack.
812 while stack and stack[-1][0] >= level:
813 stack.pop()
814 prefix = stack[-1][1] if stack else ""
815 qualified = f"{prefix}.{plain_text}" if prefix else plain_text
816 stack.append((level, qualified))
817
818 addr = f"{file_path}::{qualified}"
819 if addr in symbols:
820 qualified = f"{qualified}@L{lineno}"
821 addr = f"{file_path}::{qualified}"
822 stack[-1] = (level, qualified)
823
824 # Compute hashes over the section's raw bytes.
825 section_lines = lines[lineno - 1 : end_lineno]
826 section_bytes = "\n".join(section_lines).encode("utf-8", errors="replace")
827 body_lines = lines[lineno : end_lineno]
828 body_bytes = "\n".join(body_lines).encode("utf-8", errors="replace")
829
830 symbols[addr] = SymbolRecord(
831 kind="section",
832 name=plain_text,
833 qualified_name=qualified,
834 content_id=_sha256_bytes(_norm_ws(section_bytes)),
835 body_hash=(
836 _sha256_bytes(_norm_ws(body_bytes))
837 if body_bytes.strip()
838 else _sha256("")
839 ),
840 signature_id=_sha256(f"h{level}:{plain_text}"),
841 metadata_id=_sha256(f"level={level}"),
842 canonical_key=_canonical_key(
843 file_path, prefix, "section", plain_text, lineno
844 ),
845 lineno=lineno,
846 end_lineno=end_lineno,
847 )
848
849 return symbols
850
851 # ------------------------------------------------------------------
852 # Tree walkers
853 # ------------------------------------------------------------------
854
855 def _walk_children(
856 self,
857 node: Node,
858 src: bytes,
859 file_path: str,
860 prefix: str,
861 out: SymbolTree,
862 depth: int,
863 ) -> None:
864 """Walk direct children of *node*, dispatching to per-type emitters.
865
866 Processes ``section``, ``fenced_code_block``, and ``pipe_table``
867 nodes. All other node types (paragraphs, lists, block quotes,
868 thematic breaks) are left as implicit content inside their ancestor
869 section's ``content_id`` and are not emitted as independent symbols.
870
871 Args:
872 node: CST node whose children to walk.
873 src: Raw source bytes.
874 file_path: Workspace-relative POSIX path.
875 prefix: Dotted qualified-name accumulated so far (empty at root).
876 out: Symbol accumulator — modified in place.
877 depth: Current nesting depth; stops at ``_MAX_DEPTH``.
878 """
879 if depth > self._MAX_DEPTH:
880 return
881 for child in node.children:
882 ctype = child.type
883 if ctype == "section":
884 self._emit_section(child, src, file_path, prefix, out, depth)
885 elif ctype == "fenced_code_block":
886 self._emit_code_block(child, src, file_path, prefix, out)
887 elif ctype == "pipe_table":
888 self._emit_table(child, src, file_path, prefix, out)
889
890 def _emit_section(
891 self,
892 node: Node,
893 src: bytes,
894 file_path: str,
895 prefix: str,
896 out: SymbolTree,
897 depth: int,
898 ) -> None:
899 """Emit a ``section`` symbol for *node* and recurse into its children.
900
901 Builds a hierarchical qualified name and de-duplicates address
902 collisions by appending ``@L{lineno}``. The ``content_id`` hashes
903 the full section node bytes so that any change to content beneath
904 the heading — paragraphs, code, nested subsections — is detected.
905
906 Args:
907 node: ``section`` CST node (heading + all content beneath it).
908 src: Raw source bytes.
909 file_path: Workspace-relative POSIX path.
910 prefix: Parent qualified-name path (empty at document root).
911 out: Symbol accumulator — modified in place.
912 depth: Current nesting depth.
913 """
914 heading_node = next(
915 (c for c in node.children if c.type == "atx_heading"), None
916 )
917 if heading_node is None:
918 # Setext or unrecognised heading style — skip symbol, still recurse.
919 self._walk_children(node, src, file_path, prefix, out, depth + 1)
920 return
921
922 level, plain_text = self._extract_heading(heading_node, src)
923 if not plain_text:
924 self._walk_children(node, src, file_path, prefix, out, depth + 1)
925 return
926
927 qualified = f"{prefix}.{plain_text}" if prefix else plain_text
928 lineno = heading_node.start_point[0] + 1
929 addr = f"{file_path}::{qualified}"
930
931 # De-duplicate: two headings with identical plain text at the same
932 # level within the same parent get the second one's line number
933 # appended so addresses remain unique within the snapshot.
934 if addr in out:
935 qualified = f"{qualified}@L{lineno}"
936 addr = f"{file_path}::{qualified}"
937
938 section_bytes = _node_text(src, node)
939 # body_hash covers everything below the heading line.
940 body_bytes = src[heading_node.end_byte : node.end_byte]
941
942 out[addr] = SymbolRecord(
943 kind="section",
944 name=plain_text,
945 qualified_name=qualified,
946 content_id=_sha256_bytes(_norm_ws(section_bytes)),
947 body_hash=(
948 _sha256_bytes(_norm_ws(body_bytes))
949 if body_bytes.strip()
950 else _sha256("")
951 ),
952 signature_id=_sha256(f"h{level}:{plain_text}"),
953 metadata_id=_sha256(f"level={level}"),
954 canonical_key=_canonical_key(
955 file_path, prefix, "section", plain_text, lineno
956 ),
957 lineno=lineno,
958 end_lineno=node.end_point[0] + 1,
959 )
960
961 self._walk_children(node, src, file_path, qualified, out, depth + 1)
962
963 def _emit_code_block(
964 self,
965 node: Node,
966 src: bytes,
967 file_path: str,
968 prefix: str,
969 out: SymbolTree,
970 ) -> None:
971 """Emit a ``variable`` symbol for a fenced code block.
972
973 The symbol name encodes the language tag and start line so that
974 multiple code blocks in the same section are independently
975 addressable. ``content_id`` hashes only the fence content (not the
976 delimiter lines or language tag) so changing the language tag appears
977 in ``signature_id`` but not ``body_hash``.
978
979 Args:
980 node: ``fenced_code_block`` CST node.
981 src: Raw source bytes.
982 file_path: Workspace-relative POSIX path.
983 prefix: Containing section's qualified name (empty at root).
984 out: Symbol accumulator — modified in place.
985 """
986 lineno = node.start_point[0] + 1
987 lang = self._extract_code_lang(node, src)
988 lang_suffix = f"[{lang}]" if lang else ""
989 name = f"code{lang_suffix}@L{lineno}"
990 qualified = f"{prefix}.{name}" if prefix else name
991 addr = f"{file_path}::{qualified}"
992
993 code_bytes = self._extract_code_content(node, src)
994 normalised = _norm_ws(code_bytes)
995 out[addr] = SymbolRecord(
996 kind="variable",
997 name=name,
998 qualified_name=qualified,
999 content_id=_sha256_bytes(normalised),
1000 body_hash=_sha256_bytes(normalised),
1001 signature_id=_sha256(lang),
1002 metadata_id=_sha256(lang),
1003 canonical_key=_canonical_key(
1004 file_path, prefix, "variable", name, lineno
1005 ),
1006 lineno=lineno,
1007 end_lineno=node.end_point[0] + 1,
1008 )
1009
1010 def _emit_table(
1011 self,
1012 node: Node,
1013 src: bytes,
1014 file_path: str,
1015 prefix: str,
1016 out: SymbolTree,
1017 ) -> None:
1018 """Emit a ``section`` symbol for a GFM pipe table.
1019
1020 ``signature_id`` encodes the column headers so that schema changes
1021 are detectable independently of row data changes. ``body_hash``
1022 covers data rows only, enabling a column header rename to be
1023 distinguished from adding a new row.
1024
1025 Args:
1026 node: ``pipe_table`` CST node.
1027 src: Raw source bytes.
1028 file_path: Workspace-relative POSIX path.
1029 prefix: Containing section's qualified name (empty at root).
1030 out: Symbol accumulator — modified in place.
1031 """
1032 lineno = node.start_point[0] + 1
1033 name = f"table@L{lineno}"
1034 qualified = f"{prefix}.{name}" if prefix else name
1035 addr = f"{file_path}::{qualified}"
1036
1037 headers = self._extract_table_headers(node, src)
1038 header_sig = "|".join(headers)
1039 table_bytes = _node_text(src, node)
1040 data_bytes = self._extract_table_data_bytes(node, src)
1041
1042 out[addr] = SymbolRecord(
1043 kind="section",
1044 name=name,
1045 qualified_name=qualified,
1046 content_id=_sha256_bytes(_norm_ws(table_bytes)),
1047 body_hash=(
1048 _sha256_bytes(_norm_ws(data_bytes))
1049 if data_bytes
1050 else _sha256("")
1051 ),
1052 signature_id=_sha256(header_sig),
1053 metadata_id=_sha256(header_sig),
1054 canonical_key=_canonical_key(
1055 file_path, prefix, "section", name, lineno
1056 ),
1057 lineno=lineno,
1058 end_lineno=node.end_point[0] + 1,
1059 )
1060
1061 # ------------------------------------------------------------------
1062 # Heading extraction
1063 # ------------------------------------------------------------------
1064
1065 def _extract_heading(self, node: Node, src: bytes) -> tuple[int, str]:
1066 """Return ``(level, plain_text)`` for an ``atx_heading`` node.
1067
1068 ``level`` is 1–6. ``plain_text`` has inline markup stripped,
1069 whitespace collapsed, and is truncated to ``_MD_MAX_HEADING_LEN``
1070 characters. Returns ``(0, "")`` when the heading cannot be parsed.
1071
1072 Args:
1073 node: ``atx_heading`` CST node.
1074 src: Raw source bytes.
1075
1076 Returns:
1077 Tuple of (heading level, sanitised plain text).
1078 """
1079 level = 0
1080 raw_text = ""
1081 for child in node.children:
1082 if child.type in _MD_HEADING_LEVEL:
1083 level = _MD_HEADING_LEVEL[child.type]
1084 elif child.type == "inline":
1085 raw_text = (
1086 _node_text(src, child)
1087 .decode("utf-8", errors="replace")
1088 .strip()
1089 )
1090 return level, _plain_heading(raw_text)
1091
1092 # ------------------------------------------------------------------
1093 # Code block extraction
1094 # ------------------------------------------------------------------
1095
1096 def _extract_code_lang(self, node: Node, src: bytes) -> str:
1097 """Return the language tag from a fenced code block, or ``""``."""
1098 for child in node.children:
1099 if child.type == "info_string":
1100 for lang_node in child.children:
1101 if lang_node.type == "language":
1102 return (
1103 _node_text(src, lang_node)
1104 .decode("utf-8", errors="replace")
1105 .strip()
1106 .lower()[:40] # guard against unbounded lang tags
1107 )
1108 return ""
1109
1110 def _extract_code_content(self, node: Node, src: bytes) -> bytes:
1111 """Return the raw bytes of the code fence content (excluding delimiters)."""
1112 for child in node.children:
1113 if child.type == "code_fence_content":
1114 return _node_text(src, child)
1115 return b""
1116
1117 # ------------------------------------------------------------------
1118 # Table extraction
1119 # ------------------------------------------------------------------
1120
1121 def _extract_table_headers(self, node: Node, src: bytes) -> list[str]:
1122 """Return the column header strings from a pipe table's header row."""
1123 for child in node.children:
1124 if child.type == "pipe_table_header":
1125 return [
1126 _node_text(src, cell).decode("utf-8", errors="replace").strip()
1127 for cell in child.children
1128 if cell.type == "pipe_table_cell"
1129 ]
1130 return []
1131
1132 def _extract_table_data_bytes(self, node: Node, src: bytes) -> bytes:
1133 """Return concatenated bytes of all data rows (excludes header/delimiter)."""
1134 parts: list[bytes] = []
1135 for child in node.children:
1136 if child.type == "pipe_table_row":
1137 parts.append(_node_text(src, child))
1138 return b"".join(parts)
1139
1140 def file_content_id(self, source: bytes) -> str:
1141 """Return raw-bytes SHA-256 of *source*.
1142
1143 Markdown formatting is semantically significant (whitespace, line
1144 breaks, indentation all affect rendered output), so content IDs are
1145 intentionally byte-exact, unlike the TOML adapter which normalises
1146 key ordering and strips comments.
1147
1148 Args:
1149 source: Raw bytes of the Markdown file.
1150
1151 Returns:
1152 Hex-encoded SHA-256 digest.
1153 """
1154 return _sha256_bytes(source)
1155
1156
1157 # ---------------------------------------------------------------------------
1158 # HTML adapter — semantic element and id-bearing element extraction
1159 # ---------------------------------------------------------------------------
1160
1161 _HTML_SEMANTIC_TAGS: frozenset[str] = frozenset({
1162 "main", "header", "footer", "nav", "article", "section", "aside",
1163 "h1", "h2", "h3", "h4", "h5", "h6",
1164 "form", "fieldset", "dialog", "figure",
1165 "details", "summary",
1166 "template", "slot",
1167 })
1168
1169 _HTML_HEADINGS: frozenset[str] = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
1170
1171 # Elements whose visible text content IS their label — extract like headings.
1172 # summary → disclosure button text; figcaption → figure caption; legend → fieldset legend.
1173 _HTML_LABEL_TAGS: frozenset[str] = frozenset({"summary", "figcaption", "legend", "caption"})
1174
1175
1176 class HtmlAdapter:
1177 """Extract named HTML elements as symbols.
1178
1179 Emits a symbol for any element that can be meaningfully named:
1180
1181 - **Headings** (h1–h6): ``section`` kind, name = ``"h1: heading text"``
1182 - **id-bearing elements**: ``section`` kind, name = ``"tag#id"``
1183 - **aria-label elements**: ``section`` kind, name = ``"tag[label]"``
1184 - **Named form/fieldset/slot** (``name`` attr): ``section`` kind,
1185 name = ``"tag[name-value]"``
1186 - **Semantic elements** with a child heading: ``section`` kind,
1187 name = ``"tag: child-heading-text"``
1188 - **Custom elements** (hyphenated tags — Web Components) with id,
1189 aria-label, or name: ``section`` kind, name derived by same priority
1190 - **Semantic elements without any name signal**: ``section`` kind,
1191 name = ``"tag@lineno"`` (last resort; line-number is fragile but beats
1192 nothing for structural awareness)
1193
1194 Requires ``tree-sitter-html``; degrades to empty symbol tree without it.
1195
1196 Name-resolution priority (highest to lowest):
1197 1. ``id`` attribute → ``tag#id``
1198 2. ``aria-label`` attribute → ``tag[label]``
1199 3. ``name`` attribute (form / fieldset / slot / input) → ``tag[name]``
1200 4. Heading text content (h1–h6 only)
1201 5. First direct child heading text → ``tag: heading text``
1202 6. ``@lineno`` fallback
1203 """
1204
1205 _EXTENSIONS: frozenset[str] = frozenset({".html", ".htm"})
1206
1207 def __init__(self) -> None:
1208 self._parser: Parser | None = None
1209 try:
1210 from tree_sitter import Language, Parser
1211 import tree_sitter_html as _html
1212 lang = Language(_html.language())
1213 self._parser = Parser(lang)
1214 except Exception as exc: # noqa: BLE001
1215 logger.debug("tree-sitter-html unavailable — HTML file-level only: %s", exc)
1216
1217 def supported_extensions(self) -> frozenset[str]:
1218 return self._EXTENSIONS
1219
1220 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
1221 if self._parser is None:
1222 return {}
1223 try:
1224 tree = self._parser.parse(source)
1225 except Exception as exc: # noqa: BLE001
1226 logger.debug("HTML parse error in %s: %s", file_path, exc)
1227 return {}
1228 symbols: SymbolTree = {}
1229 self._walk(tree.root_node, source, file_path, symbols)
1230 return symbols
1231
1232 def _walk(self, node: Node, src: bytes, file_path: str, out: SymbolTree) -> None:
1233 if node.type == "element":
1234 self._try_emit(node, src, file_path, out)
1235 for child in node.children:
1236 self._walk(child, src, file_path, out)
1237
1238 def _try_emit(
1239 self, node: Node, src: bytes, file_path: str, out: SymbolTree
1240 ) -> None:
1241 start_tag = next((c for c in node.children if c.type == "start_tag"), None)
1242 if start_tag is None:
1243 return
1244 tag_node = start_tag.child_by_field_name("name")
1245 if tag_node is None:
1246 tag_node = next(
1247 (c for c in start_tag.named_children if c.type == "tag_name"), None
1248 )
1249 if tag_node is None:
1250 return
1251 tag = _node_text(src, tag_node).decode("utf-8", errors="replace").lower()
1252
1253 is_heading = tag in _HTML_HEADINGS
1254 is_label = tag in _HTML_LABEL_TAGS
1255 is_semantic = tag in _HTML_SEMANTIC_TAGS
1256 is_custom = "-" in tag # Web Component custom element
1257
1258 # Attribute lookups — ordered by resolution priority.
1259 element_id = self._find_attr(start_tag, src, "id")
1260 aria_label = self._find_attr(start_tag, src, "aria-label")
1261 # ``name`` attr is meaningful on form, fieldset, slot, and input.
1262 name_attr = (
1263 self._find_attr(start_tag, src, "name")
1264 if tag in {"form", "fieldset", "slot", "input"}
1265 else ""
1266 )
1267
1268 has_name_signal = bool(element_id or aria_label or name_attr)
1269
1270 if not (is_heading or is_label or is_semantic or is_custom or has_name_signal):
1271 return
1272
1273 lineno = node.start_point[0] + 1
1274
1275 if element_id:
1276 name = f"{tag}#{element_id}"
1277 elif aria_label:
1278 name = f"{tag}[{aria_label}]"
1279 elif name_attr:
1280 name = f"{tag}[{name_attr}]"
1281 elif is_heading or is_label:
1282 # Headings and label elements (summary, figcaption, legend, caption):
1283 # the visible text content IS the symbol name.
1284 text_parts = [
1285 _node_text(src, c).decode("utf-8", errors="replace").strip()
1286 for c in node.named_children
1287 if c.type == "text"
1288 ]
1289 text = " ".join(text_parts).strip() or tag
1290 name = f"{tag}: {text}"
1291 else:
1292 # Semantic / custom element without an explicit name attribute.
1293 # Try to derive a name from the first direct child heading — this
1294 # gives `section: About Us` instead of the fragile `section@42`.
1295 child_heading = self._find_child_heading(node, src)
1296 if child_heading:
1297 name = f"{tag}: {child_heading}"
1298 else:
1299 name = f"{tag}@{lineno}"
1300
1301 addr = f"{file_path}::{name}"
1302 node_bytes = _node_text(src, node)
1303 out[addr] = SymbolRecord(
1304 kind="section",
1305 name=name,
1306 qualified_name=name,
1307 content_id=_sha256_bytes(_norm_ws(node_bytes)),
1308 body_hash=_sha256_bytes(_norm_ws(node_bytes)),
1309 signature_id=_sha256(name),
1310 metadata_id="",
1311 canonical_key=_canonical_key(file_path, "", "section", name, lineno),
1312 lineno=lineno,
1313 end_lineno=node.end_point[0] + 1,
1314 )
1315
1316 def _find_attr(self, start_tag: Node, src: bytes, attr_name: str) -> str:
1317 """Return the value of *attr_name* on *start_tag*, or ``""``."""
1318 for attr in start_tag.named_children:
1319 if attr.type != "attribute":
1320 continue
1321 children = attr.named_children
1322 if not children:
1323 continue
1324 name = _node_text(src, children[0]).decode("utf-8", errors="replace").strip()
1325 if name == attr_name and len(children) >= 2:
1326 val = _node_text(src, children[-1]).decode("utf-8", errors="replace")
1327 return val.strip('"\'').strip()
1328 return ""
1329
1330 def _find_child_heading(self, element_node: Node, src: bytes) -> str:
1331 """Return the text content of the first direct child heading element, or ``""``."""
1332 for child in element_node.children:
1333 if child.type != "element":
1334 continue
1335 start = next((c for c in child.children if c.type == "start_tag"), None)
1336 if start is None:
1337 continue
1338 tag_node = next(
1339 (c for c in start.named_children if c.type == "tag_name"), None
1340 )
1341 if tag_node is None:
1342 continue
1343 child_tag = _node_text(src, tag_node).decode("utf-8", errors="replace").lower()
1344 if child_tag in _HTML_HEADINGS:
1345 text_parts = [
1346 _node_text(src, c).decode("utf-8", errors="replace").strip()
1347 for c in child.named_children
1348 if c.type == "text"
1349 ]
1350 return " ".join(text_parts).strip()
1351 return ""
1352
1353 def file_content_id(self, source: bytes) -> str:
1354 return _sha256_bytes(source)
1355
1356
1357 # ---------------------------------------------------------------------------
1358 # TOML adapter — stdlib tomllib, zero external dependencies
1359 # ---------------------------------------------------------------------------
1360
1361 # Recursive type alias for the complete set of values tomllib can produce.
1362 # PEP 695 syntax (Python 3.12+); recursive aliases are fully supported.
1363 type _TomlScalar = (
1364 str | int | float | bool
1365 | datetime.datetime | datetime.date | datetime.time
1366 )
1367 type _TomlValue = _TomlScalar | list[_TomlValue] | dict[str, _TomlValue]
1368
1369
1370 class TomlAdapter:
1371 """TOML configuration file adapter — Python 3.11+ stdlib :mod:`tomllib`.
1372
1373 Extracts addressable symbols from ``*.toml`` files so that Muse can detect
1374 *which* tables and keys changed rather than just *that the file changed*.
1375
1376 Symbol taxonomy
1377 ---------------
1378 ``section``
1379 A TOML table (``[project]``, ``[tool.mypy]``) or an array-of-tables
1380 entry (``[[tool.mypy.overrides]]``). The symbol name is the full
1381 dotted path; array entries carry an integer index suffix, e.g.
1382 ``tool.mypy.overrides[0]``.
1383
1384 ``variable``
1385 A scalar key-value pair (``name = "muse"``, ``strict = true``) or a
1386 list whose elements are not all tables. The symbol name is the dotted
1387 key path; the value is encoded in ``body_hash`` so that two keys
1388 holding the same value share a ``body_hash`` regardless of their name
1389 (rename detection).
1390
1391 Semantic content IDs
1392 --------------------
1393 Both ``file_content_id`` and the per-symbol ``content_id`` / ``body_hash``
1394 are computed from ``json.dumps(value, sort_keys=True, default=str)``. This
1395 makes content IDs:
1396
1397 - **Comment-insensitive** — TOML comments are stripped by the parser.
1398 - **Key-order-insensitive** — ``sort_keys=True`` normalises key ordering.
1399 - **Whitespace-insensitive** — ``tomllib`` discards insignificant whitespace.
1400 - **Date-stable** — ``datetime`` objects serialise to ISO 8601 strings via
1401 ``default=str``, which is deterministic.
1402
1403 Line-number limitation
1404 ----------------------
1405 :mod:`tomllib` does not expose positional information. All symbols are
1406 recorded with ``lineno=0`` and ``end_lineno=0``. Because TOML forbids
1407 duplicate keys within a table, the combination ``(file, prefix, kind, key)``
1408 is already globally unique, so ``canonical_key`` remains collision-free
1409 despite the constant line number.
1410
1411 Depth limit
1412 -----------
1413 Recursion stops at ``_MAX_DEPTH`` (default 6) to prevent enormous configs
1414 from emitting thousands of symbols. Tables beyond that depth are still
1415 hashed into their ancestor's ``content_id``; they simply do not appear as
1416 independent symbols.
1417 """
1418
1419 _EXTENSIONS: frozenset[str] = frozenset({".toml"})
1420 _MAX_DEPTH: int = 6
1421
1422 def supported_extensions(self) -> frozenset[str]:
1423 return self._EXTENSIONS
1424
1425 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
1426 """Extract table and key-value symbols from *source*.
1427
1428 Args:
1429 source: Raw bytes of the TOML file.
1430 file_path: Workspace-relative POSIX path — used as address prefix.
1431
1432 Returns:
1433 A :type:`SymbolTree` mapping dotted addresses to
1434 :class:`SymbolRecord` dicts. Returns an empty dict on parse
1435 errors so that the caller falls back to file-level diffing.
1436 """
1437 try:
1438 data = tomllib.loads(source.decode("utf-8", errors="replace"))
1439 except tomllib.TOMLDecodeError:
1440 return {}
1441 symbols: SymbolTree = {}
1442 self._walk(data, file_path, "", symbols, 0)
1443 return symbols
1444
1445 def _walk(
1446 self,
1447 mapping: _TomlMapping,
1448 file_path: str,
1449 prefix: str,
1450 out: SymbolTree,
1451 depth: int,
1452 ) -> None:
1453 """Recursively emit :class:`SymbolRecord` entries for *mapping*.
1454
1455 Args:
1456 mapping: The TOML dict to walk (top-level or a nested table).
1457 file_path: Workspace-relative path — used as address prefix.
1458 prefix: Dotted key path accumulated so far (empty at top level).
1459 out: Accumulator — modified in place.
1460 depth: Current nesting depth; stops recursing at ``_MAX_DEPTH``.
1461 """
1462 if depth > self._MAX_DEPTH:
1463 return
1464 for key, value in mapping.items():
1465 dotted = f"{prefix}.{key}" if prefix else key
1466 addr = f"{file_path}::{dotted}"
1467
1468 if isinstance(value, dict):
1469 # Inline table or [section] — emit as section, then recurse.
1470 content = json.dumps(value, sort_keys=True, default=str)
1471 out[addr] = SymbolRecord(
1472 kind="section",
1473 name=key,
1474 qualified_name=dotted,
1475 content_id=_sha256(content),
1476 body_hash=_sha256(content),
1477 signature_id=_sha256(dotted),
1478 metadata_id="",
1479 canonical_key=_canonical_key(file_path, prefix, "section", key, 0),
1480 lineno=0,
1481 end_lineno=0,
1482 )
1483 self._walk(value, file_path, dotted, out, depth + 1)
1484
1485 elif (
1486 isinstance(value, list)
1487 and len(value) > 0
1488 and all(isinstance(item, dict) for item in value)
1489 ):
1490 # [[array.of.tables]] — each entry becomes an indexed section.
1491 for idx, item in enumerate(value):
1492 entry_name = f"{dotted}[{idx}]"
1493 entry_addr = f"{file_path}::{entry_name}"
1494 content = json.dumps(item, sort_keys=True, default=str)
1495 out[entry_addr] = SymbolRecord(
1496 kind="section",
1497 name=entry_name,
1498 qualified_name=entry_name,
1499 content_id=_sha256(content),
1500 body_hash=_sha256(content),
1501 signature_id=_sha256(entry_name),
1502 metadata_id="",
1503 canonical_key=_canonical_key(
1504 file_path, prefix, "section", key, idx
1505 ),
1506 lineno=idx,
1507 end_lineno=idx,
1508 )
1509 # isinstance guard needed for mypy narrowing from _TomlValue.
1510 if isinstance(item, dict):
1511 self._walk(item, file_path, entry_name, out, depth + 1)
1512
1513 else:
1514 # Scalar or mixed list — emit as variable.
1515 # content_id binds the key so two different keys with the same
1516 # value produce different content_ids. body_hash binds only
1517 # the value so that a rename (same value, new key) is detected.
1518 val_str = json.dumps(value, sort_keys=True, default=str)
1519 out[addr] = SymbolRecord(
1520 kind="variable",
1521 name=key,
1522 qualified_name=dotted,
1523 content_id=_sha256(f"{dotted}={val_str}"),
1524 body_hash=_sha256(val_str),
1525 signature_id=_sha256(dotted),
1526 metadata_id="",
1527 canonical_key=_canonical_key(
1528 file_path, prefix, "variable", key, 0
1529 ),
1530 lineno=0,
1531 end_lineno=0,
1532 )
1533
1534 def file_content_id(self, source: bytes) -> str:
1535 """Return a semantic content ID for the whole file.
1536
1537 Computed from ``json.dumps(parsed, sort_keys=True, default=str)`` so
1538 the ID is insensitive to comments, key ordering, and whitespace.
1539 Falls back to raw-bytes SHA-256 when parsing fails.
1540
1541 Args:
1542 source: Raw bytes of the TOML file.
1543
1544 Returns:
1545 Hex-encoded SHA-256 digest.
1546 """
1547 try:
1548 data = tomllib.loads(source.decode("utf-8", errors="replace"))
1549 return _sha256(json.dumps(data, sort_keys=True, default=str))
1550 except Exception: # noqa: BLE001
1551 return _sha256_bytes(source)
1552
1553
1554 # ---------------------------------------------------------------------------
1555 # tree-sitter adapter — shared infrastructure for all non-Python languages
1556 # ---------------------------------------------------------------------------
1557
1558 _WS_RE: re.Pattern[bytes] = re.compile(rb"\s+")
1559
1560
1561 def _norm_ws(src: bytes) -> bytes:
1562 """Collapse all whitespace runs to a single space and strip the result."""
1563 return _WS_RE.sub(b" ", src).strip()
1564
1565
1566 def _node_text(src: bytes, node: Node) -> bytes:
1567 """Extract the raw source bytes covered by a tree-sitter node."""
1568 return src[node.start_byte : node.end_byte]
1569
1570
1571 def _resolve_scss_selector(src: bytes, rule_set_node: "Node") -> str:
1572 """Return the fully expanded CSS selector for a (possibly nested) SCSS rule_set.
1573
1574 tree-sitter-scss handles two forms of ``&`` nesting differently:
1575
1576 * ``&:hover`` / ``&.mod`` — the ``&`` is kept as a ``nesting_selector``
1577 node *inside* the ``selectors`` child, so the raw text contains ``&``.
1578 * ``&__name`` (BEM suffix) — tree-sitter splits this into a preceding
1579 ``ERROR`` node containing a ``nesting_selector`` (the bare ``&``) and a
1580 ``rule_set`` whose ``selectors`` child is just ``__name``. The ``&`` is
1581 not present in the captured text at all.
1582
1583 Both forms are detected and resolved recursively so that multi-level
1584 nesting like ``.a { &__b { &__c {} } }`` produces ``.a__b__c``.
1585 """
1586 sel_node: "Node | None" = None
1587 for child in rule_set_node.children:
1588 if child.type == "selectors":
1589 sel_node = child
1590 break
1591 if sel_node is None:
1592 return ""
1593
1594 raw_txt = _node_text(src, sel_node).decode("utf-8", errors="replace").strip()
1595
1596 # Case 1 — explicit & (e.g. `&:hover`, `&.active` kept by tree-sitter-css)
1597 has_explicit_amp = "&" in raw_txt
1598
1599 # Case 2 — implicit & — tree-sitter split `&__name` into a preceding ERROR
1600 # sibling that contains a nesting_selector, then this rule_set with `__name`.
1601 has_implicit_amp = False
1602 parent_block = rule_set_node.parent
1603 if parent_block is not None and parent_block.type == "block":
1604 prev: "Node | None" = None
1605 for child in parent_block.children:
1606 # tree-sitter wraps each access in a new Python object — use
1607 # byte-position identity instead of `is`.
1608 if child.start_byte == rule_set_node.start_byte:
1609 break
1610 prev = child
1611 if prev is not None and prev.type == "ERROR":
1612 has_implicit_amp = any(c.type == "nesting_selector" for c in prev.children)
1613
1614 if not has_explicit_amp and not has_implicit_amp:
1615 return raw_txt
1616
1617 # Find nearest ancestor rule_set for the parent selector.
1618 parent_rule_set: "Node | None" = None
1619 node = rule_set_node.parent
1620 while node is not None and node.type != "stylesheet":
1621 if node.type == "rule_set":
1622 parent_rule_set = node
1623 break
1624 node = node.parent
1625
1626 if parent_rule_set is None:
1627 # & at top level — strip it (produces the bare suffix as a symbol name).
1628 return raw_txt.replace("&", "").strip() if has_explicit_amp else raw_txt
1629
1630 parent_resolved = _resolve_scss_selector(src, parent_rule_set)
1631
1632 if has_explicit_amp:
1633 return raw_txt.replace("&", parent_resolved)
1634 # has_implicit_amp: selector is `__name`, parent is `.block` → `.block__name`
1635 return parent_resolved + raw_txt
1636
1637
1638 def _class_name_from(src: bytes, node: Node, field: str) -> str | None:
1639 """Extract a class/struct name from a parent CST node.
1640
1641 Tries ``child_by_field_name(field)`` first (covers Java, C#, C++, Rust).
1642 Falls back to the first ``identifier``-typed named child to handle
1643 languages like Kotlin where the class name is not a named field.
1644 """
1645 child = node.child_by_field_name(field)
1646 if child is None:
1647 for c in node.named_children:
1648 if c.type == "identifier":
1649 child = c
1650 break
1651 if child is None:
1652 return None
1653 return _node_text(src, child).decode("utf-8", errors="replace")
1654
1655
1656 def _qualified_name_ts(
1657 src: bytes,
1658 sym_node: Node,
1659 name: str,
1660 class_node_types: frozenset[str],
1661 class_name_field: str,
1662 ) -> str:
1663 """Walk the CST parent chain to build a dotted qualified name.
1664
1665 For a method ``bark`` inside ``class Dog``, returns ``"Dog.bark"``.
1666 For a top-level function, returns just ``"standalone"``.
1667 """
1668 parts = [name]
1669 parent = sym_node.parent
1670 while parent is not None:
1671 if parent.type in class_node_types:
1672 class_name = _class_name_from(src, parent, class_name_field)
1673 if class_name:
1674 parts.insert(0, class_name)
1675 parent = parent.parent
1676 return ".".join(parts)
1677
1678
1679 class LangSpec(TypedDict):
1680 """Per-language tree-sitter configuration consumed by :class:`TreeSitterAdapter`.
1681
1682 Each entry in :data:`_TS_LANG_SPECS` is one of these dicts. The ``name``
1683 field is the canonical lowercase identifier used in ``.muse/code_config.toml``
1684 ``[code] languages`` lists — e.g. ``"javascript"``, ``"typescript"``,
1685 ``"bash"``. Users who want symbol-level analysis only for a subset of
1686 languages write::
1687
1688 [code]
1689 languages = ["python", "typescript", "css"]
1690
1691 Any spec whose ``name`` is absent from that list is replaced by a
1692 :class:`FallbackAdapter` for the current process, so the grammar package
1693 need not be uninstalled — just un-listed.
1694 """
1695
1696 name: str # Canonical lowercase language identifier used in code_config.toml
1697 extensions: frozenset[str]
1698 module_name: str # Python import name, e.g. ``"tree_sitter_javascript"``
1699 lang_func: str # Attribute on the module returning the raw capsule
1700 query_str: str # tree-sitter S-expr query — must capture ``@sym`` and ``@name``
1701 kind_map: _KindMap # CST node type → SymbolKind
1702 child_kind_overrides: _KindMap # direct-child node type → SymbolKind override;
1703 # checked after kind_map to refine parent-node kinds
1704 # (e.g. Go type_spec with struct_type child → "struct")
1705 class_node_types: frozenset[str] # Ancestor types that scope methods
1706 class_name_field: str # Field name for the class name (e.g. ``"name"`` or ``"type"``)
1707 receiver_capture: str # Capture name for Go-style method receivers; ``""`` to skip
1708 async_node_child: str # Direct child type marking async (``"async"`` for JS/TS; ``""`` to skip)
1709 resolve_selector: bool # True for CSS/SCSS: expand ``&`` nesting in rule_set selectors
1710
1711
1712 class TreeSitterAdapter:
1713 """Implements :class:`LanguageAdapter` using tree-sitter for real CST parsing.
1714
1715 tree-sitter is the same parsing technology used by GitHub Copilot, VS Code,
1716 Neovim, and Zed. It produces a concrete syntax tree from every source file,
1717 even if the file has syntax errors — making it suitable for real-world repos
1718 that may contain partially-written code.
1719
1720 Parsing is error-tolerant: individual file failures are logged at DEBUG
1721 level and return an empty :type:`SymbolTree` so the caller falls back to
1722 file-level diffing rather than crashing.
1723 """
1724
1725 def __init__(
1726 self,
1727 spec: LangSpec,
1728 parser: Parser,
1729 query: Query,
1730 ) -> None:
1731 self._spec = spec
1732 self._parser = parser
1733 self._query = query
1734
1735 def supported_extensions(self) -> frozenset[str]:
1736 return self._spec["extensions"]
1737
1738 def parse_symbols(self, source: bytes, file_path: str) -> SymbolTree:
1739 from tree_sitter import QueryCursor
1740 try:
1741 tree = self._parser.parse(source)
1742 cursor = QueryCursor(self._query)
1743 symbols: SymbolTree = {}
1744 recv_cap = self._spec["receiver_capture"]
1745
1746 for _pat, caps in cursor.matches(tree.root_node):
1747 sym_list = caps.get("sym", [])
1748 name_list = caps.get("name", [])
1749 if not sym_list or not name_list:
1750 continue
1751 sym_node = sym_list[0]
1752 name_node = name_list[0]
1753
1754 name_txt = _node_text(source, name_node).decode(
1755 "utf-8", errors="replace"
1756 )
1757 # SCSS nesting: expand `&` refs into the compiled class name.
1758 # tree-sitter-css splits `&__name` into a preceding ERROR node
1759 # and a rule_set with selector `__name` — _resolve_scss_selector
1760 # handles both BEM suffixes and pseudo-class forms (&:hover).
1761 if sym_node.type == "rule_set" and self._spec["resolve_selector"]:
1762 name_txt = _resolve_scss_selector(source, sym_node)
1763 kind = self._spec["kind_map"].get(sym_node.type, "function")
1764
1765 # Refine kind using direct child node types. Used for languages
1766 # like Go where the same parent node type (``type_spec``) covers
1767 # structs, interfaces, and plain aliases — the child distinguishes.
1768 child_overrides = self._spec["child_kind_overrides"]
1769 if child_overrides:
1770 for child in sym_node.children:
1771 if child.type in child_overrides:
1772 kind = child_overrides[child.type]
1773 break
1774
1775 # Promote function/method to async variant when the node has
1776 # an "async" keyword as a direct child (JS, TS, Swift, etc.).
1777 async_child = self._spec["async_node_child"]
1778 if async_child and kind in ("function", "method"):
1779 if any(c.type == async_child for c in sym_node.children[:3]):
1780 kind = "async_function" if kind == "function" else "async_method"
1781
1782 # Build qualified name — walking ancestor chain for methods.
1783 qualified = _qualified_name_ts(
1784 source,
1785 sym_node,
1786 name_txt,
1787 self._spec["class_node_types"],
1788 self._spec["class_name_field"],
1789 )
1790
1791 # Go-style receiver prefix: (d *Dog) → "Dog.Bark"
1792 if recv_cap:
1793 recv_list = caps.get(recv_cap, [])
1794 if recv_list:
1795 recv_txt = (
1796 _node_text(source, recv_list[0])
1797 .decode("utf-8", errors="replace")
1798 .lstrip("*")
1799 .strip()
1800 )
1801 if recv_txt:
1802 qualified = f"{recv_txt}.{qualified}"
1803
1804 addr = f"{file_path}::{qualified}"
1805 node_bytes = _node_text(source, sym_node)
1806 name_bytes = _node_text(source, name_node)
1807 # Substitute the name with a placeholder to isolate the body
1808 # from the identifier — two symbols with the same body but
1809 # different names share the same body_hash, signalling a rename.
1810 body_bytes = node_bytes.replace(name_bytes, b"\xfe", 1)
1811
1812 params_node = (
1813 sym_node.child_by_field_name("parameters")
1814 or sym_node.child_by_field_name("formal_parameters")
1815 or sym_node.child_by_field_name("function_value_parameters")
1816 )
1817 params_bytes = (
1818 _node_text(source, params_node)
1819 if params_node is not None
1820 else b""
1821 )
1822
1823 sym_lineno = sym_node.start_point[0] + 1
1824 # Determine class prefix for canonical_key (dotted scope path).
1825 scope_prefix = ".".join(qualified.split(".")[:-1]) + "." if "." in qualified else ""
1826 symbols[addr] = SymbolRecord(
1827 kind=kind,
1828 name=name_txt,
1829 qualified_name=qualified,
1830 content_id=_sha256_bytes(_norm_ws(node_bytes)),
1831 body_hash=_sha256_bytes(_norm_ws(body_bytes)),
1832 signature_id=_sha256_bytes(_norm_ws(name_bytes + params_bytes)),
1833 # metadata_id: tree-sitter adapters extract annotations/visibility
1834 # where available. Currently stubbed as "" — future adapters can
1835 # enrich this by reading modifier nodes.
1836 metadata_id="",
1837 canonical_key=_canonical_key(file_path, scope_prefix, kind, name_txt, sym_lineno),
1838 lineno=sym_lineno,
1839 end_lineno=sym_node.end_point[0] + 1,
1840 )
1841 return symbols
1842 except Exception as exc: # noqa: BLE001
1843 logger.debug("tree-sitter parse error in %s: %s", file_path, exc)
1844 return {}
1845
1846 def file_content_id(self, source: bytes) -> str:
1847 """Whitespace-normalised SHA-256 of the source — insensitive to reformatting."""
1848 return _sha256_bytes(_norm_ws(source))
1849
1850 def validate_source(self, source: bytes) -> str | None:
1851 """Return an error description if *source* has syntax errors, else None.
1852
1853 tree-sitter always produces a parse tree even for broken code.
1854 Errors appear as nodes with ``type == "ERROR"`` or ``is_missing == True``.
1855 ``root_node.has_error`` is the fast top-level check.
1856 """
1857 try:
1858 tree = self._parser.parse(source)
1859 except Exception as exc: # noqa: BLE001
1860 return f"parser error: {exc}"
1861
1862 if not tree.root_node.has_error:
1863 return None
1864
1865 # Walk the tree to find the first concrete error site.
1866 error_node = _first_error_node(tree.root_node)
1867 if error_node is not None:
1868 line = error_node.start_point[0] + 1
1869 fragment = source[
1870 error_node.start_byte : min(error_node.end_byte, error_node.start_byte + 60)
1871 ].decode("utf-8", errors="replace").strip()
1872 msg = f"syntax error on line {line}"
1873 if fragment:
1874 msg += f": {fragment!r}"
1875 return msg
1876 return "syntax error (unknown location)"
1877
1878
1879 def _make_ts_adapter(spec: LangSpec) -> LanguageAdapter:
1880 """Build a :class:`TreeSitterAdapter`; fall back to :class:`FallbackAdapter` on error.
1881
1882 Importing the grammar capsule is deferred to this factory so that a
1883 missing or incompatible grammar package degrades gracefully rather than
1884 preventing the entire plugin from loading. tree_sitter itself is also
1885 imported here — not at module level — so that importing ast_parser does
1886 not pay the C-extension load cost unless semantic analysis is actually
1887 requested.
1888 """
1889 try:
1890 import warnings
1891 from tree_sitter import Language, Parser, Query
1892 mod = importlib.import_module(spec["module_name"])
1893 raw_lang = getattr(mod, spec["lang_func"])()
1894 # tree_sitter_scss 1.0.0 returns an int (old C-pointer API) while all
1895 # other grammars return a PyCapsule. Suppress the "int argument
1896 # support is deprecated" DeprecationWarning that tree-sitter emits
1897 # when it receives an int so that the warning does not surface in tests
1898 # and user output. The int path still works — it is only deprecated.
1899 with warnings.catch_warnings():
1900 warnings.filterwarnings(
1901 "ignore",
1902 message="int argument support is deprecated",
1903 category=DeprecationWarning,
1904 )
1905 lang = Language(raw_lang)
1906 parser = Parser(lang)
1907 query = Query(lang, spec["query_str"])
1908 return TreeSitterAdapter(spec, parser, query)
1909 except Exception as exc: # noqa: BLE001
1910 logger.debug(
1911 "tree-sitter grammar %s.%s unavailable — using file-level fallback: %s",
1912 spec["module_name"],
1913 spec["lang_func"],
1914 exc,
1915 )
1916 return FallbackAdapter(spec["extensions"])
1917
1918
1919 # ---------------------------------------------------------------------------
1920 # Per-language tree-sitter specs
1921 # ---------------------------------------------------------------------------
1922
1923 _JS_SPEC: LangSpec = {
1924 "name": "javascript",
1925 "extensions": frozenset({".js", ".jsx", ".mjs", ".cjs"}),
1926 "module_name": "tree_sitter_javascript",
1927 "lang_func": "language",
1928 # tree-sitter-javascript uses "class" for named class expressions.
1929 # Arrow functions and function expressions assigned to variables are
1930 # captured via variable_declarator so that `const greet = () => {}` is
1931 # a first-class symbol.
1932 "query_str": (
1933 "(function_declaration name: (identifier) @name) @sym\n"
1934 "(function_expression name: (identifier) @name) @sym\n"
1935 "(generator_function_declaration name: (identifier) @name) @sym\n"
1936 "(class_declaration name: (identifier) @name) @sym\n"
1937 "(class name: (identifier) @name) @sym\n"
1938 "(method_definition name: (property_identifier) @name) @sym\n"
1939 "(variable_declarator name: (identifier) @name"
1940 " value: (arrow_function)) @sym\n"
1941 "(variable_declarator name: (identifier) @name"
1942 " value: (function_expression)) @sym\n"
1943 "(variable_declarator name: (identifier) @name"
1944 " value: (generator_function)) @sym"
1945 ),
1946 "kind_map": {
1947 "function_declaration": "function",
1948 "function_expression": "function",
1949 "generator_function_declaration": "function",
1950 "class_declaration": "class",
1951 "class": "class",
1952 "method_definition": "method",
1953 "variable_declarator": "function",
1954 },
1955 "child_kind_overrides": {},
1956 "class_node_types": frozenset({"class_declaration", "class"}),
1957 "class_name_field": "name",
1958 "receiver_capture": "",
1959 # async keyword appears as a direct child token on function/method nodes.
1960 "async_node_child": "async",
1961 "resolve_selector": False,
1962 }
1963
1964 _TS_QUERY = (
1965 # TypeScript uses type_identifier (not identifier) for class names.
1966 "(function_declaration name: (identifier) @name) @sym\n"
1967 "(function_expression name: (identifier) @name) @sym\n"
1968 "(generator_function_declaration name: (identifier) @name) @sym\n"
1969 "(class_declaration name: (type_identifier) @name) @sym\n"
1970 "(class name: (type_identifier) @name) @sym\n"
1971 "(abstract_class_declaration name: (type_identifier) @name) @sym\n"
1972 "(method_definition name: (property_identifier) @name) @sym\n"
1973 "(interface_declaration name: (type_identifier) @name) @sym\n"
1974 "(type_alias_declaration name: (type_identifier) @name) @sym\n"
1975 "(enum_declaration name: (identifier) @name) @sym\n"
1976 "(internal_module name: (identifier) @name) @sym\n"
1977 "(variable_declarator name: (identifier) @name"
1978 " value: (arrow_function)) @sym\n"
1979 "(variable_declarator name: (identifier) @name"
1980 " value: (function_expression)) @sym\n"
1981 "(variable_declarator name: (identifier) @name"
1982 " value: (generator_function)) @sym"
1983 )
1984
1985 _TS_KIND_MAP: _KindMap = {
1986 "function_declaration": "function",
1987 "function_expression": "function",
1988 "generator_function_declaration": "function",
1989 "class_declaration": "class",
1990 "class": "class",
1991 "abstract_class_declaration": "class",
1992 "method_definition": "method",
1993 "interface_declaration": "interface",
1994 "type_alias_declaration": "type_alias",
1995 "enum_declaration": "enum",
1996 "internal_module": "namespace",
1997 "variable_declarator": "function",
1998 }
1999
2000 _TS_CLASS_NODES: frozenset[str] = frozenset(
2001 {"class_declaration", "class", "abstract_class_declaration"}
2002 )
2003
2004 _TS_SPEC: LangSpec = {
2005 "name": "typescript",
2006 "extensions": frozenset({".ts"}),
2007 "module_name": "tree_sitter_typescript",
2008 "lang_func": "language_typescript",
2009 "query_str": _TS_QUERY,
2010 "kind_map": _TS_KIND_MAP,
2011 "child_kind_overrides": {},
2012 "class_node_types": _TS_CLASS_NODES,
2013 "class_name_field": "name",
2014 "receiver_capture": "",
2015 "async_node_child": "async",
2016 "resolve_selector": False,
2017 }
2018
2019 _TSX_SPEC: LangSpec = {
2020 "name": "tsx",
2021 "extensions": frozenset({".tsx"}),
2022 "module_name": "tree_sitter_typescript",
2023 "lang_func": "language_tsx",
2024 "query_str": _TS_QUERY,
2025 "kind_map": _TS_KIND_MAP,
2026 "child_kind_overrides": {},
2027 "class_node_types": _TS_CLASS_NODES,
2028 "class_name_field": "name",
2029 "receiver_capture": "",
2030 "async_node_child": "async",
2031 "resolve_selector": False,
2032 }
2033
2034 _GO_SPEC: LangSpec = {
2035 "name": "go",
2036 "extensions": frozenset({".go"}),
2037 "module_name": "tree_sitter_go",
2038 "lang_func": "language",
2039 "query_str": (
2040 "(function_declaration name: (identifier) @name) @sym\n"
2041 "(method_declaration\n"
2042 " receiver: (parameter_list\n"
2043 " (parameter_declaration type: _ @recv))\n"
2044 " name: (field_identifier) @name) @sym\n"
2045 "(type_spec name: (type_identifier) @name) @sym\n"
2046 "(const_spec name: (identifier) @name) @sym\n"
2047 "(var_spec name: (identifier) @name) @sym"
2048 ),
2049 "kind_map": {
2050 "function_declaration": "function",
2051 "method_declaration": "method",
2052 "type_spec": "type_alias", # refined by child_kind_overrides below
2053 "const_spec": "variable",
2054 "var_spec": "variable",
2055 },
2056 # Go type_spec covers struct, interface, and plain type aliases — the direct
2057 # child node type is the discriminant. child_kind_overrides refines the
2058 # parent-level kind after the kind_map lookup.
2059 "child_kind_overrides": {
2060 "struct_type": "struct",
2061 "interface_type": "interface",
2062 },
2063 "class_node_types": frozenset(),
2064 "class_name_field": "name",
2065 "receiver_capture": "recv",
2066 "async_node_child": "",
2067 "resolve_selector": False,
2068 }
2069
2070 _RUST_SPEC: LangSpec = {
2071 "name": "rust",
2072 "extensions": frozenset({".rs"}),
2073 "module_name": "tree_sitter_rust",
2074 "lang_func": "language",
2075 "query_str": (
2076 "(function_item name: (identifier) @name) @sym\n"
2077 "(struct_item name: (type_identifier) @name) @sym\n"
2078 "(enum_item name: (type_identifier) @name) @sym\n"
2079 "(trait_item name: (type_identifier) @name) @sym\n"
2080 "(type_item name: (type_identifier) @name) @sym\n"
2081 "(mod_item name: (identifier) @name) @sym\n"
2082 "(static_item name: (identifier) @name) @sym\n"
2083 "(const_item name: (identifier) @name) @sym"
2084 ),
2085 "kind_map": {
2086 "function_item": "function",
2087 "struct_item": "struct",
2088 "enum_item": "enum",
2089 "trait_item": "trait",
2090 "type_item": "type_alias",
2091 "mod_item": "module",
2092 "static_item": "variable",
2093 "const_item": "variable",
2094 },
2095 "child_kind_overrides": {},
2096 # impl_item scopes methods; its implementing type is in the "type" field.
2097 "class_node_types": frozenset({"impl_item"}),
2098 "class_name_field": "type",
2099 "receiver_capture": "",
2100 "async_node_child": "",
2101 "resolve_selector": False,
2102 }
2103
2104 _JAVA_SPEC: LangSpec = {
2105 "name": "java",
2106 "extensions": frozenset({".java"}),
2107 "module_name": "tree_sitter_java",
2108 "lang_func": "language",
2109 "query_str": (
2110 "(method_declaration name: (identifier) @name) @sym\n"
2111 "(constructor_declaration name: (identifier) @name) @sym\n"
2112 "(class_declaration name: (identifier) @name) @sym\n"
2113 "(interface_declaration name: (identifier) @name) @sym\n"
2114 "(enum_declaration name: (identifier) @name) @sym\n"
2115 "(annotation_type_declaration name: (identifier) @name) @sym\n"
2116 "(record_declaration name: (identifier) @name) @sym"
2117 ),
2118 "kind_map": {
2119 "method_declaration": "method",
2120 "constructor_declaration": "function",
2121 "class_declaration": "class",
2122 "interface_declaration": "interface",
2123 "enum_declaration": "enum",
2124 "annotation_type_declaration": "class",
2125 "record_declaration": "struct",
2126 },
2127 "child_kind_overrides": {},
2128 "class_node_types": frozenset(
2129 {"class_declaration", "interface_declaration", "enum_declaration", "record_declaration"}
2130 ),
2131 "class_name_field": "name",
2132 "receiver_capture": "",
2133 "async_node_child": "",
2134 "resolve_selector": False,
2135 }
2136
2137 _C_SPEC: LangSpec = {
2138 "name": "c",
2139 "extensions": frozenset({".c", ".h"}),
2140 "module_name": "tree_sitter_c",
2141 "lang_func": "language",
2142 "query_str": (
2143 "(function_definition\n"
2144 " declarator: (function_declarator\n"
2145 " declarator: (identifier) @name)) @sym\n"
2146 # Structs and enums defined via typedef or direct declaration.
2147 "(struct_specifier name: (type_identifier) @name) @sym\n"
2148 "(enum_specifier name: (type_identifier) @name) @sym"
2149 ),
2150 "kind_map": {
2151 "function_definition": "function",
2152 "struct_specifier": "struct",
2153 "enum_specifier": "enum",
2154 },
2155 "child_kind_overrides": {},
2156 "class_node_types": frozenset(),
2157 "class_name_field": "name",
2158 "receiver_capture": "",
2159 "async_node_child": "",
2160 "resolve_selector": False,
2161 }
2162
2163 _CPP_SPEC: LangSpec = {
2164 "name": "cpp",
2165 "extensions": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hxx"}),
2166 "module_name": "tree_sitter_cpp",
2167 "lang_func": "language",
2168 "query_str": (
2169 # Plain function definitions (top-level or namespaced).
2170 "(function_definition\n"
2171 " declarator: (function_declarator\n"
2172 " declarator: (identifier) @name)) @sym\n"
2173 # Out-of-class method definitions: void Dog::bark() {}
2174 "(function_definition\n"
2175 " declarator: (function_declarator\n"
2176 " declarator: (qualified_identifier\n"
2177 " name: (identifier) @name))) @sym\n"
2178 "(class_specifier name: (type_identifier) @name) @sym\n"
2179 "(struct_specifier name: (type_identifier) @name) @sym\n"
2180 "(enum_specifier name: (type_identifier) @name) @sym\n"
2181 "(namespace_definition (namespace_identifier) @name) @sym"
2182 ),
2183 "kind_map": {
2184 "function_definition": "function",
2185 "class_specifier": "class",
2186 "struct_specifier": "struct",
2187 "enum_specifier": "enum",
2188 "namespace_definition": "namespace",
2189 },
2190 "child_kind_overrides": {},
2191 "class_node_types": frozenset({"class_specifier", "struct_specifier"}),
2192 "class_name_field": "name",
2193 "receiver_capture": "",
2194 "async_node_child": "",
2195 "resolve_selector": False,
2196 }
2197
2198 _CS_SPEC: LangSpec = {
2199 "name": "csharp",
2200 "extensions": frozenset({".cs"}),
2201 "module_name": "tree_sitter_c_sharp",
2202 "lang_func": "language",
2203 "query_str": (
2204 "(method_declaration name: (identifier) @name) @sym\n"
2205 "(constructor_declaration name: (identifier) @name) @sym\n"
2206 "(class_declaration name: (identifier) @name) @sym\n"
2207 "(interface_declaration name: (identifier) @name) @sym\n"
2208 "(enum_declaration name: (identifier) @name) @sym\n"
2209 "(struct_declaration name: (identifier) @name) @sym\n"
2210 "(record_declaration name: (identifier) @name) @sym\n"
2211 "(property_declaration name: (identifier) @name) @sym"
2212 ),
2213 "kind_map": {
2214 "method_declaration": "method",
2215 "constructor_declaration": "function",
2216 "class_declaration": "class",
2217 "interface_declaration": "interface",
2218 "enum_declaration": "enum",
2219 "struct_declaration": "struct",
2220 "record_declaration": "struct",
2221 "property_declaration": "variable",
2222 },
2223 "child_kind_overrides": {},
2224 "class_node_types": frozenset(
2225 {"class_declaration", "interface_declaration", "struct_declaration", "record_declaration"}
2226 ),
2227 "class_name_field": "name",
2228 "receiver_capture": "",
2229 "async_node_child": "",
2230 "resolve_selector": False,
2231 }
2232
2233 _RUBY_SPEC: LangSpec = {
2234 "name": "ruby",
2235 "extensions": frozenset({".rb"}),
2236 "module_name": "tree_sitter_ruby",
2237 "lang_func": "language",
2238 "query_str": (
2239 "(method name: (identifier) @name) @sym\n"
2240 "(singleton_method name: (identifier) @name) @sym\n"
2241 "(class name: (constant) @name) @sym\n"
2242 "(module name: (constant) @name) @sym\n"
2243 "(singleton_class value: (self) @name) @sym"
2244 ),
2245 "kind_map": {
2246 "method": "method",
2247 "singleton_method": "method",
2248 "class": "class",
2249 "module": "module",
2250 "singleton_class": "class",
2251 },
2252 "child_kind_overrides": {},
2253 "class_node_types": frozenset({"class", "module"}),
2254 "class_name_field": "name",
2255 "receiver_capture": "",
2256 "async_node_child": "",
2257 "resolve_selector": False,
2258 }
2259
2260 _KT_SPEC: LangSpec = {
2261 "name": "kotlin",
2262 "extensions": frozenset({".kt", ".kts"}),
2263 "module_name": "tree_sitter_kotlin",
2264 "lang_func": "language",
2265 # Kotlin uses plain `identifier` for all names (no type_identifier or
2266 # simple_identifier variants at this grammar version).
2267 "query_str": (
2268 "(function_declaration (identifier) @name) @sym\n"
2269 "(class_declaration (identifier) @name) @sym\n"
2270 "(object_declaration (identifier) @name) @sym\n"
2271 "(property_declaration (variable_declaration"
2272 " (identifier) @name)) @sym"
2273 ),
2274 "kind_map": {
2275 "function_declaration": "function",
2276 "class_declaration": "class",
2277 "object_declaration": "object", # singleton — semantically distinct from class
2278 "property_declaration": "variable",
2279 },
2280 "child_kind_overrides": {},
2281 # Kotlin methods are function_declaration nodes inside class_body.
2282 # child_by_field_name("name") is None for Kotlin classes; _class_name_from
2283 # falls back to the first identifier-typed named child automatically.
2284 # object_declaration excluded: methods on a singleton scope as Singleton.method
2285 # but object_declaration itself is not a "class" container for instantiation.
2286 "class_node_types": frozenset({"class_declaration", "object_declaration"}),
2287 "class_name_field": "name",
2288 "receiver_capture": "",
2289 "async_node_child": "",
2290 "resolve_selector": False,
2291 }
2292
2293 # Swift: requires py-tree-sitter-swift (builds from source). _make_ts_adapter
2294 # degrades to FallbackAdapter if the package is not available.
2295 _SWIFT_SPEC: LangSpec = {
2296 "name": "swift",
2297 "extensions": frozenset({".swift"}),
2298 "module_name": "py_tree_sitter_swift",
2299 "lang_func": "language",
2300 "query_str": (
2301 "(function_declaration name: (simple_identifier) @name) @sym\n"
2302 "(class_declaration name: (type_identifier) @name) @sym\n"
2303 "(struct_declaration name: (type_identifier) @name) @sym\n"
2304 "(enum_declaration name: (type_identifier) @name) @sym\n"
2305 "(protocol_declaration name: (type_identifier) @name) @sym\n"
2306 "(typealias_declaration name: (type_identifier) @name) @sym\n"
2307 "(computed_property (simple_identifier) @name) @sym\n"
2308 "(init_declaration) @sym"
2309 ),
2310 "kind_map": {
2311 "function_declaration": "function",
2312 "class_declaration": "class",
2313 "struct_declaration": "struct",
2314 "enum_declaration": "enum",
2315 "protocol_declaration": "trait", # Swift protocol ≈ Rust trait
2316 "typealias_declaration": "type_alias",
2317 "computed_property": "variable",
2318 "init_declaration": "function",
2319 },
2320 "child_kind_overrides": {},
2321 "class_node_types": frozenset(
2322 {"class_declaration", "struct_declaration", "enum_declaration"}
2323 ),
2324 "class_name_field": "name",
2325 "receiver_capture": "",
2326 "async_node_child": "async",
2327 "resolve_selector": False,
2328 }
2329
2330 # CSS: rule-sets, @keyframes, @media, @supports, and @layer become addressable
2331 # symbols so diffs report "selector .button changed" instead of "file changed".
2332 # tree-sitter-css handles all modern CSS syntax including cascade layers.
2333 #
2334 # @media query naming: three child types cover all real-world forms:
2335 # keyword_query → @media print
2336 # feature_query → @media (max-width: 768px)
2337 # binary_query → @media screen and (min-width: 1024px)
2338 _CSS_SPEC: LangSpec = {
2339 "name": "css",
2340 "extensions": frozenset({".css"}),
2341 "module_name": "tree_sitter_css",
2342 "lang_func": "language",
2343 "query_str": (
2344 # Selector rules — the primary symbol in CSS.
2345 "(rule_set (selectors) @name) @sym\n"
2346 # Named animation blocks.
2347 "(keyframes_statement (keyframes_name) @name) @sym\n"
2348 # @media — three child types for the query condition:
2349 "(media_statement (keyword_query) @name) @sym\n"
2350 "(media_statement (feature_query) @name) @sym\n"
2351 "(media_statement (binary_query) @name) @sym\n"
2352 # @supports (display: grid) { … }
2353 "(supports_statement (feature_query) @name) @sym\n"
2354 # @layer base { … } / @layer utilities { … }
2355 "(at_rule (keyword_query) @name) @sym"
2356 ),
2357 "kind_map": {
2358 "rule_set": "rule",
2359 "keyframes_statement": "rule",
2360 "media_statement": "rule",
2361 "supports_statement": "rule",
2362 "at_rule": "rule",
2363 },
2364 "child_kind_overrides": {},
2365 "class_node_types": frozenset(),
2366 "class_name_field": "name",
2367 "receiver_capture": "",
2368 "async_node_child": "",
2369 "resolve_selector": False, # plain CSS has no `&` nesting
2370 }
2371
2372 # SCSS: superset of CSS with variables, mixins, and functions as first-class
2373 # named symbols — the most important constructs in a design-system stylesheet.
2374 # Uses tree-sitter-scss (separate grammar from tree-sitter-css).
2375 #
2376 # Symbol taxonomy:
2377 # variable — $primary-color: #333; (top-level $var declarations)
2378 # mixin — @mixin flex-center($dir) { … } (outputs CSS, @include'd)
2379 # function — @function em($px) { @return … } (returns a value)
2380 # rule — .button { … }, @keyframes, @media, @supports
2381 #
2382 # SCSS nesting: `&:hover` / `&__element` (BEM) is expanded by
2383 # _resolve_scss_selector so nested rules carry their compiled selector as the
2384 # symbol address instead of a bare `&` or suffix fragment.
2385 _SCSS_SPEC: LangSpec = {
2386 "name": "scss",
2387 "extensions": frozenset({".scss"}),
2388 "module_name": "tree_sitter_scss",
2389 "lang_func": "language",
2390 "query_str": (
2391 # $variable declarations at stylesheet level only (not property values
2392 # inside rule blocks, which are also `declaration` nodes).
2393 "(stylesheet (declaration (property_name) @name) @sym)\n"
2394 # @mixin name(…) { … } — parameterized CSS output blocks.
2395 "(mixin_statement (identifier) @name) @sym\n"
2396 # @function name(…) { … } — returns a computed value.
2397 "(function_statement (identifier) @name) @sym\n"
2398 # Selector rule-sets (same as CSS).
2399 "(rule_set (selectors) @name) @sym\n"
2400 # Named animation blocks.
2401 "(keyframes_statement (keyframes_name) @name) @sym\n"
2402 # @media — three child types for the query condition:
2403 "(media_statement (keyword_query) @name) @sym\n"
2404 "(media_statement (feature_query) @name) @sym\n"
2405 "(media_statement (binary_query) @name) @sym"
2406 ),
2407 "kind_map": {
2408 "declaration": "variable", # $var: value at stylesheet level
2409 "mixin_statement": "mixin",
2410 "function_statement": "function",
2411 "rule_set": "rule",
2412 "keyframes_statement": "rule",
2413 "media_statement": "rule",
2414 },
2415 "child_kind_overrides": {},
2416 "class_node_types": frozenset(),
2417 "class_name_field": "name",
2418 "receiver_capture": "",
2419 "async_node_child": "",
2420 "resolve_selector": True, # expand SCSS `&` nesting in rule_set selectors
2421 }
2422
2423 # Shell: bash/sh/zsh function definitions and variable assignments.
2424 # tree-sitter-bash handles both POSIX sh and bash/zsh syntax (function_definition
2425 # nodes cover ``function foo() {}`` and ``foo() {}`` forms; zsh superset is parsed
2426 # correctly because it is backward-compatible with bash at the AST level).
2427 _BASH_SPEC: LangSpec = {
2428 "name": "bash",
2429 "extensions": frozenset({".sh", ".bash", ".zsh", ".plugin.zsh"}),
2430 "module_name": "tree_sitter_bash",
2431 "lang_func": "language",
2432 "query_str": (
2433 # Named function definitions: function foo() {} and foo() {}
2434 "(function_definition name: (word) @name) @sym\n"
2435 # Top-level variable assignments: FOO=bar ARRAY=(...)
2436 "(variable_assignment name: (variable_name) @name) @sym"
2437 ),
2438 "kind_map": {
2439 "function_definition": "function",
2440 "variable_assignment": "variable",
2441 },
2442 "child_kind_overrides": {},
2443 "class_node_types": frozenset(),
2444 "class_name_field": "name",
2445 "receiver_capture": "",
2446 "async_node_child": "",
2447 "resolve_selector": False,
2448 }
2449
2450 #: All tree-sitter language specs, loaded in registration order.
2451 _TS_LANG_SPECS: list[LangSpec] = [
2452 _JS_SPEC,
2453 _TS_SPEC,
2454 _TSX_SPEC,
2455 _GO_SPEC,
2456 _RUST_SPEC,
2457 _JAVA_SPEC,
2458 _C_SPEC,
2459 _CPP_SPEC,
2460 _CS_SPEC,
2461 _RUBY_SPEC,
2462 _KT_SPEC,
2463 _SWIFT_SPEC,
2464 _CSS_SPEC,
2465 _SCSS_SPEC,
2466 _BASH_SPEC,
2467 ]
2468
2469
2470 # ---------------------------------------------------------------------------
2471 # Per-repo language configuration (.muse/code_config.toml [code] section)
2472 # ---------------------------------------------------------------------------
2473
2474 from dataclasses import dataclass as _dataclass
2475
2476
2477 @_dataclass
2478 class CodeConfig:
2479 """Parsed ``[code]`` section from ``.muse/code_config.toml``.
2480
2481 Controls which tree-sitter language adapters are active for the current
2482 repository. The defaults reproduce the previous "all installed grammars"
2483 behaviour so that repos without a config file are unaffected.
2484
2485 Example ``.muse/code_config.toml``::
2486
2487 # Python + TypeScript web project — skip Go, Rust, Java, …
2488 [code]
2489 languages = ["python", "typescript", "tsx", "css", "html", "bash"]
2490
2491 [framework_detection]
2492 # framework config unchanged
2493
2494 Language names correspond to the ``name`` field of :class:`LangSpec` plus
2495 the built-in adapters: ``"python"``, ``"markdown"``, ``"html"``, ``"toml"``.
2496
2497 When ``languages`` is absent or ``None``, every installed grammar is active
2498 (the zero-config default).
2499 """
2500
2501 active_languages: frozenset[str] | None = None # None → all installed grammars enabled
2502
2503
2504 def load_code_config(root: pathlib.Path) -> CodeConfig:
2505 """Read ``[code]`` from ``.muse/code_config.toml`` at *root*.
2506
2507 Returns a :class:`CodeConfig` with defaults (all languages active) when:
2508
2509 * the file does not exist,
2510 * the file is malformed TOML,
2511 * the ``[code]`` section is absent, or
2512 * ``languages`` is not a list of strings.
2513
2514 Args:
2515 root: Absolute path to the repository root (the directory containing
2516 ``.muse/``).
2517
2518 Returns:
2519 A :class:`CodeConfig` describing which languages should have
2520 symbol-level analysis for this process.
2521 """
2522 config_path = root / ".muse" / "code_config.toml"
2523 if not config_path.exists():
2524 return CodeConfig()
2525
2526 try:
2527 with open(config_path, "rb") as fh:
2528 raw = tomllib.load(fh)
2529 except Exception as exc: # noqa: BLE001
2530 logger.warning("⚠️ Could not parse .muse/code_config.toml: %s", exc)
2531 return CodeConfig()
2532
2533 section = raw.get("code", {})
2534 if not isinstance(section, dict):
2535 return CodeConfig()
2536
2537 langs_raw = section.get("languages")
2538 if langs_raw is None:
2539 return CodeConfig()
2540
2541 if not isinstance(langs_raw, list) or not all(isinstance(x, str) for x in langs_raw):
2542 logger.warning(
2543 "⚠️ .muse/code_config.toml [code] languages must be a list of strings; ignoring"
2544 )
2545 return CodeConfig()
2546
2547 return CodeConfig(active_languages=frozenset(s.lower().strip() for s in langs_raw))
2548
2549
2550 # ---------------------------------------------------------------------------
2551 # Adapter registry and public helpers
2552 # ---------------------------------------------------------------------------
2553
2554 #: Fallback adapter for file types without a registered adapter — always cheap.
2555 _FALLBACK: FallbackAdapter = FallbackAdapter(frozenset())
2556
2557 #: Internal caches — populated on first call to :func:`_adapters`.
2558 _ADAPTERS_CACHE: list[LanguageAdapter] | None = None
2559 _SEM_EXT_CACHE: frozenset[str] | None = None
2560
2561 #: Active language filter — ``None`` means all installed grammars are enabled.
2562 #: Set once per process via :func:`configure_language_filter` before the first
2563 #: call to :func:`_adapters`. Changing it after the cache is built has no
2564 #: effect; call :func:`reset_adapter_cache` first if you need to re-configure
2565 #: (test-only — production code never resets the cache mid-process).
2566 _LANGUAGE_FILTER: frozenset[str] | None = None
2567
2568 #: Public lazy attributes — real values injected by ``__getattr__`` on first
2569 #: access. The annotations here exist solely so the symbol graph can index
2570 #: these names; the runtime values are set via ``setattr`` in :func:`_adapters`.
2571 ADAPTERS: list[LanguageAdapter]
2572 SEMANTIC_EXTENSIONS: frozenset[str]
2573
2574
2575 def configure_language_filter(names: frozenset[str] | None) -> None:
2576 """Restrict which tree-sitter grammars are active for this process.
2577
2578 Must be called **before** the first use of :func:`adapter_for_path`,
2579 :func:`parse_symbols`, or any command that touches the symbol graph.
2580 Calling it after the cache is already built has no effect — use
2581 :func:`reset_adapter_cache` first (tests only).
2582
2583 Args:
2584 names: Lowercase language names to keep active (e.g.
2585 ``frozenset({"python", "typescript", "bash"})``).
2586 ``None`` re-enables all installed grammars (the default).
2587
2588 Example — call from a command's ``run()`` after ``require_repo()``::
2589
2590 root = require_repo()
2591 cfg = load_code_config(root)
2592 configure_language_filter(cfg.active_languages)
2593 """
2594 global _LANGUAGE_FILTER
2595 _LANGUAGE_FILTER = names
2596
2597
2598 def reset_adapter_cache() -> None:
2599 """Clear the adapter cache so the next call to :func:`_adapters` rebuilds it.
2600
2601 **Test use only.** Production code — where ``require_repo()`` is called
2602 once per process — never needs this. Tests that call
2603 :func:`configure_language_filter` with different values in the same
2604 process must call this between them so the new filter takes effect.
2605 """
2606 global _ADAPTERS_CACHE, _SEM_EXT_CACHE
2607 _ADAPTERS_CACHE = None
2608 _SEM_EXT_CACHE = None
2609 # Remove promoted module attributes so __getattr__ re-fires on next access.
2610 _mod: _types.ModuleType = sys.modules[__name__]
2611 for attr in ("ADAPTERS", "SEMANTIC_EXTENSIONS"):
2612 try:
2613 delattr(_mod, attr)
2614 except AttributeError:
2615 pass
2616
2617
2618 #: Built-in adapter names that are always available regardless of installed
2619 #: grammar packages. When a language filter is active, these names are
2620 #: recognised so users can include ``"python"`` or ``"markdown"`` in their
2621 #: ``languages`` list and have it work as expected.
2622 _BUILTIN_ADAPTER_NAMES: frozenset[str] = frozenset({"python", "markdown", "html", "toml"})
2623
2624
2625 def _adapters() -> list[LanguageAdapter]:
2626 """Return the global adapter list, building it on first call.
2627
2628 Tree-sitter grammar packages are imported here — not at module level —
2629 so that importing :mod:`ast_parser` costs nothing for commands that do
2630 not perform semantic analysis (e.g. ``muse init``, ``muse log``).
2631
2632 When :data:`_LANGUAGE_FILTER` is set (via :func:`configure_language_filter`),
2633 only specs whose ``name`` appears in the filter are built as real adapters;
2634 all others become :class:`FallbackAdapter` instances. Built-in adapters
2635 (Python, Markdown, HTML, TOML) are included if their name is in the filter
2636 or if the filter is ``None``.
2637 """
2638 global _ADAPTERS_CACHE, _SEM_EXT_CACHE
2639 if _ADAPTERS_CACHE is None:
2640 lang_filter = _LANGUAGE_FILTER # snapshot — avoids TOCTOU if threads ever appear
2641 builtin_adapters: list[LanguageAdapter] = []
2642 for adapter_name, adapter_cls in (
2643 ("python", PythonAdapter),
2644 ("markdown", MarkdownAdapter),
2645 ("html", HtmlAdapter),
2646 ("toml", TomlAdapter),
2647 ):
2648 if lang_filter is None or adapter_name in lang_filter:
2649 builtin_adapters.append(adapter_cls())
2650 result: list[LanguageAdapter] = builtin_adapters
2651 for spec in _TS_LANG_SPECS:
2652 if lang_filter is None or spec["name"] in lang_filter:
2653 result.append(_make_ts_adapter(spec))
2654 # Specs excluded by the filter produce no adapter — their extensions
2655 # fall through to FallbackAdapter in adapter_for_path().
2656 _ADAPTERS_CACHE = result
2657 _SEM_EXT_CACHE = frozenset().union(
2658 *(a.supported_extensions() for a in result if not isinstance(a, FallbackAdapter))
2659 )
2660 # Promote computed values to real module attributes so subsequent
2661 # attribute lookups bypass __getattr__ and become O(1) dict access.
2662 _mod: _types.ModuleType = sys.modules[__name__]
2663 setattr(_mod, "ADAPTERS", result)
2664 setattr(_mod, "SEMANTIC_EXTENSIONS", _SEM_EXT_CACHE)
2665 return _ADAPTERS_CACHE
2666
2667
2668 def _semantic_extensions() -> frozenset[str]:
2669 """Return the set of extensions with AST-level support, building it on first call."""
2670 _adapters()
2671 if _SEM_EXT_CACHE is None:
2672 return frozenset()
2673 return _SEM_EXT_CACHE
2674
2675
2676 def __getattr__(name: str) -> list[LanguageAdapter] | frozenset[str]:
2677 """Lazy module attributes — ADAPTERS and SEMANTIC_EXTENSIONS.
2678
2679 Both are computed on first access (which triggers adapter building) and
2680 then cached as real module attributes so subsequent lookups are O(1).
2681 """
2682 if name == "ADAPTERS":
2683 return _adapters()
2684 if name == "SEMANTIC_EXTENSIONS":
2685 return _semantic_extensions()
2686 raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
2687
2688
2689 def adapter_for_path(file_path: str) -> LanguageAdapter:
2690 """Return the best :class:`LanguageAdapter` for *file_path*.
2691
2692 Checks registered adapters in order; falls back to
2693 :class:`FallbackAdapter` when no adapter claims the suffix.
2694
2695 Args:
2696 file_path: Workspace-relative POSIX path (e.g. ``"src/utils.py"``).
2697
2698 Returns:
2699 The first adapter whose :meth:`~LanguageAdapter.supported_extensions`
2700 set contains the file's lowercase suffix.
2701 """
2702 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
2703 for adapter in _adapters():
2704 if suffix in adapter.supported_extensions():
2705 return adapter
2706 return _FALLBACK
2707
2708
2709 def parse_symbols(source: bytes, file_path: str) -> SymbolTree:
2710 """Parse *source* with the best available adapter for *file_path*.
2711
2712 Args:
2713 source: Raw bytes of the source file.
2714 file_path: Workspace-relative POSIX path.
2715
2716 Returns:
2717 A :type:`SymbolTree` (may be empty for unsupported file types).
2718 """
2719 return adapter_for_path(file_path).parse_symbols(source, file_path)
2720
2721
2722 def file_content_id(source: bytes, file_path: str) -> str:
2723 """Return the semantic content ID for *file_path* given its raw *source*.
2724
2725 Args:
2726 source: Raw bytes of the file.
2727 file_path: Workspace-relative POSIX path.
2728
2729 Returns:
2730 Hex-encoded SHA-256 digest — AST-based for Python, raw-bytes for others.
2731 """
2732 return adapter_for_path(file_path).file_content_id(source)
2733
2734
2735 def _first_error_node(node: Node) -> Node | None:
2736 """Return the first ERROR or MISSING node in *node*'s subtree, depth-first."""
2737 if node.type == "ERROR" or node.is_missing:
2738 return node
2739 for child in node.children:
2740 found = _first_error_node(child)
2741 if found is not None:
2742 return found
2743 return None
2744
2745
2746 def validate_syntax(source: bytes, file_path: str) -> str | None:
2747 """Return a human-readable error description if *source* has syntax errors.
2748
2749 Covers Python (via :mod:`ast`) and all tree-sitter languages. Returns
2750 ``None`` for valid files and for file types without a parser.
2751
2752 This is used by ``muse patch`` to verify that a surgical replacement
2753 does not introduce a syntax error before writing the result to disk.
2754
2755 Args:
2756 source: UTF-8 encoded source bytes to validate.
2757 file_path: Workspace-relative path — used to select the parser.
2758
2759 Returns:
2760 A human-readable error string, or ``None`` if the file is valid.
2761 """
2762 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
2763
2764 if suffix in {".py", ".pyi"}:
2765 if len(source) > MAX_AST_BYTES:
2766 return None # too large to validate — treat as structurally valid
2767 try:
2768 ast.parse(source)
2769 return None
2770 except SyntaxError as exc:
2771 return f"syntax error on line {exc.lineno}: {exc.msg}"
2772
2773 adapter = adapter_for_path(file_path)
2774 if isinstance(adapter, TreeSitterAdapter):
2775 return adapter.validate_source(source)
2776
2777 # MarkdownAdapter and HtmlAdapter use tree-sitter internally when available
2778 # but do not expose validation — no syntax errors to report for prose files.
2779 return None
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago