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