descry-codegraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
descry/scip/support.py ADDED
@@ -0,0 +1,174 @@
1
+ """SCIP support with auto-discovery for multiple languages.
2
+
3
+ Provides functions to detect SCIP indexer availability and manage
4
+ SCIP-based symbol resolution for improved descry call resolution.
5
+
6
+ SCIP (Source Code Index Protocol) is a format from Sourcegraph that
7
+ provides type-aware symbol information from language servers.
8
+
9
+ Supported indexers:
10
+ - rust-analyzer: Rust
11
+ - scip-typescript: TypeScript, JavaScript
12
+ - scip-python: Python
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from typing import TYPE_CHECKING
22
+
23
+ from descry._env import safe_env
24
+
25
+ if TYPE_CHECKING:
26
+ from typing import Dict
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Global state (lazy-initialized)
31
+ _indexer_cache: Dict[str, dict] = {}
32
+
33
+
34
+ def _check_indexer(name: str, command: str, version_flag: str = "--version") -> dict:
35
+ """Check if an indexer is available.
36
+
37
+ Args:
38
+ name: Indexer name for logging
39
+ command: Command to check for
40
+ version_flag: Flag to get version info
41
+
42
+ Returns:
43
+ Dict with available, path, version keys
44
+ """
45
+ result = {"available": False, "path": None, "version": None}
46
+
47
+ path = shutil.which(command)
48
+ if path:
49
+ try:
50
+ proc = subprocess.run(
51
+ [path, version_flag],
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=10,
55
+ env=safe_env(),
56
+ )
57
+ if proc.returncode == 0:
58
+ result["available"] = True
59
+ result["path"] = path
60
+ result["version"] = proc.stdout.strip() or proc.stderr.strip()
61
+ logger.debug(f"SCIP: {name} available at {path}")
62
+ else:
63
+ logger.debug(f"SCIP: {name} found but failed to run")
64
+ except subprocess.TimeoutExpired:
65
+ logger.debug(f"SCIP: {name} version check timed out")
66
+ except (OSError, FileNotFoundError) as e:
67
+ logger.debug(f"SCIP: {name} check failed: {e}")
68
+ else:
69
+ logger.debug(f"SCIP: {name} not found in PATH")
70
+
71
+ return result
72
+
73
+
74
+ def rust_analyzer_available() -> bool:
75
+ """Check if rust-analyzer is available for Rust SCIP generation."""
76
+ if os.environ.get("DESCRY_NO_SCIP"):
77
+ return False
78
+
79
+ if "rust-analyzer" not in _indexer_cache:
80
+ _indexer_cache["rust-analyzer"] = _check_indexer(
81
+ "rust-analyzer", "rust-analyzer", "--version"
82
+ )
83
+ return _indexer_cache["rust-analyzer"]["available"]
84
+
85
+
86
+ def scip_typescript_available() -> bool:
87
+ """Check if scip-typescript is available for TypeScript/JavaScript SCIP generation."""
88
+ if os.environ.get("DESCRY_NO_SCIP"):
89
+ return False
90
+
91
+ if "scip-typescript" not in _indexer_cache:
92
+ _indexer_cache["scip-typescript"] = _check_indexer(
93
+ "scip-typescript", "scip-typescript", "--version"
94
+ )
95
+ return _indexer_cache["scip-typescript"]["available"]
96
+
97
+
98
+ def scip_python_available() -> bool:
99
+ """Check if scip-python is available for Python SCIP generation.
100
+
101
+ scip-python is a Sourcegraph-maintained indexer distributed via npm
102
+ (@sourcegraph/scip-python) that installs a `scip-python` binary.
103
+ """
104
+ if os.environ.get("DESCRY_NO_SCIP"):
105
+ return False
106
+
107
+ if "scip-python" not in _indexer_cache:
108
+ _indexer_cache["scip-python"] = _check_indexer(
109
+ "scip-python", "scip-python", "--version"
110
+ )
111
+ return _indexer_cache["scip-python"]["available"]
112
+
113
+
114
+ def scip_available() -> bool:
115
+ """Check if any SCIP indexer is available (for backwards compatibility).
116
+
117
+ Returns:
118
+ True if at least one SCIP indexer is available.
119
+ """
120
+ if os.environ.get("DESCRY_NO_SCIP"):
121
+ return False
122
+
123
+ return (
124
+ rust_analyzer_available()
125
+ or scip_typescript_available()
126
+ or scip_python_available()
127
+ )
128
+
129
+
130
+ def get_scip_status() -> dict:
131
+ """Return SCIP status for diagnostics.
132
+
133
+ Returns:
134
+ Dictionary with indexer availability information.
135
+ """
136
+ # Force availability checks
137
+ _ = rust_analyzer_available()
138
+ _ = scip_typescript_available()
139
+ _ = scip_python_available()
140
+
141
+ rust_info = _indexer_cache.get("rust-analyzer", {})
142
+ ts_info = _indexer_cache.get("scip-typescript", {})
143
+ py_info = _indexer_cache.get("scip-python", {})
144
+
145
+ return {
146
+ "available": scip_available(),
147
+ "disabled_by_env": bool(os.environ.get("DESCRY_NO_SCIP")),
148
+ "indexers": {
149
+ "rust-analyzer": {
150
+ "available": rust_info.get("available", False),
151
+ "path": rust_info.get("path"),
152
+ "version": rust_info.get("version"),
153
+ },
154
+ "scip-typescript": {
155
+ "available": ts_info.get("available", False),
156
+ "path": ts_info.get("path"),
157
+ "version": ts_info.get("version"),
158
+ },
159
+ "scip-python": {
160
+ "available": py_info.get("available", False),
161
+ "path": py_info.get("path"),
162
+ "version": py_info.get("version"),
163
+ },
164
+ },
165
+ # Backwards compatibility
166
+ "rust_analyzer_path": rust_info.get("path"),
167
+ "rust_analyzer_version": rust_info.get("version"),
168
+ }
169
+
170
+
171
+ def reset_scip_state():
172
+ """Reset cached SCIP state. Useful for testing."""
173
+ global _indexer_cache
174
+ _indexer_cache = {}
@@ -0,0 +1,356 @@
1
+ """Optional tree-sitter-backed parser for TypeScript / TSX / JavaScript.
2
+
3
+ Activated when the ``descry-codegraph[ast]`` extra is installed (providing
4
+ ``tree-sitter``, ``tree-sitter-typescript``, ``tree-sitter-javascript``) and
5
+ the user opts in via ``.descry.toml``::
6
+
7
+ [features]
8
+ use_tree_sitter_ts = true
9
+
10
+ When enabled, ``generate.TSParser`` routes file parsing through this module
11
+ and falls back to regex-only extraction for files the AST walker cannot
12
+ process. When disabled or when the extra is missing, behaviour is
13
+ indistinguishable from the regex-only baseline.
14
+
15
+ This parser extracts the symbol shapes needed for the call graph:
16
+
17
+ - ``class_declaration`` / ``interface_declaration`` / ``enum_declaration``
18
+ - ``function_declaration``
19
+ - ``method_definition`` (including ``get``/``set``) inside a class body
20
+ - Arrow / function-expression RHS of ``const foo = …``
21
+ - ``import_statement`` (ES) and ``call_expression`` (call edges)
22
+
23
+ Anything the grammar hits as ``ERROR`` is surfaced to the caller; the
24
+ BaseParser fallback can then re-run regex-only extraction on that file.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING, Literal
33
+
34
+ if TYPE_CHECKING:
35
+ import tree_sitter
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ # --- Optional dependency bootstrap -----------------------------------------
41
+
42
+ _TS_AVAILABLE: bool | None = None
43
+ _TS_LANGUAGE = None
44
+ _TSX_LANGUAGE = None
45
+ _JS_LANGUAGE = None
46
+
47
+
48
+ def tree_sitter_available() -> bool:
49
+ """True iff tree-sitter + the TS/JS grammars are importable.
50
+
51
+ Result is cached on first call.
52
+ """
53
+ global _TS_AVAILABLE, _TS_LANGUAGE, _TSX_LANGUAGE, _JS_LANGUAGE
54
+ if _TS_AVAILABLE is not None:
55
+ return _TS_AVAILABLE
56
+ try:
57
+ import tree_sitter as ts
58
+ import tree_sitter_typescript as tsts
59
+ import tree_sitter_javascript as tsjs
60
+
61
+ _TS_LANGUAGE = ts.Language(tsts.language_typescript())
62
+ _TSX_LANGUAGE = ts.Language(tsts.language_tsx())
63
+ _JS_LANGUAGE = ts.Language(tsjs.language())
64
+ _TS_AVAILABLE = True
65
+ except Exception as exc: # pragma: no cover - defensive
66
+ logger.debug("tree-sitter unavailable: %s", exc)
67
+ _TS_AVAILABLE = False
68
+ return _TS_AVAILABLE
69
+
70
+
71
+ def _language_for(file_path: str | Path):
72
+ """Return the tree-sitter Language for a given TS/TSX/JS path, or None."""
73
+ if not tree_sitter_available():
74
+ return None
75
+ suffix = Path(file_path).suffix.lower()
76
+ if suffix == ".tsx":
77
+ return _TSX_LANGUAGE
78
+ if suffix in (".ts", ".d.ts"):
79
+ return _TS_LANGUAGE
80
+ if suffix in (".js", ".jsx", ".mjs", ".cjs"):
81
+ return _JS_LANGUAGE
82
+ return None
83
+
84
+
85
+ # --- Symbol data classes ---------------------------------------------------
86
+
87
+
88
+ SymbolKind = Literal[
89
+ "Class",
90
+ "Interface",
91
+ "Enum",
92
+ "Function",
93
+ "Method",
94
+ "Const",
95
+ "Import",
96
+ "Call",
97
+ ]
98
+
99
+
100
+ @dataclass
101
+ class ExtractedSymbol:
102
+ kind: SymbolKind
103
+ name: str
104
+ lineno: int
105
+ end_lineno: int
106
+ parent_name: str | None = None
107
+ signature: str | None = None
108
+ docstring: str | None = None
109
+ is_async: bool = False
110
+ is_static: bool = False
111
+ accessor: Literal["get", "set", None] | None = None
112
+ module: str | None = None # populated for Import
113
+ imported_names: list[str] = field(default_factory=list) # for Import
114
+
115
+
116
+ @dataclass
117
+ class ParseResult:
118
+ """Symbols + raw call sites extracted from a single file."""
119
+
120
+ symbols: list[ExtractedSymbol] = field(default_factory=list)
121
+ calls: list[tuple[int, str]] = field(default_factory=list) # (lineno, callee)
122
+ had_errors: bool = False
123
+
124
+
125
+ # --- Extractor -------------------------------------------------------------
126
+
127
+
128
+ def _node_text(node: "tree_sitter.Node") -> str:
129
+ return node.text.decode("utf-8", errors="replace") if node.text else ""
130
+
131
+
132
+ def _first_child_of_type(
133
+ node: "tree_sitter.Node", type_name: str
134
+ ) -> "tree_sitter.Node | None":
135
+ for child in node.named_children:
136
+ if child.type == type_name:
137
+ return child
138
+ return None
139
+
140
+
141
+ def _line(node: "tree_sitter.Node") -> int:
142
+ return node.start_point[0] + 1
143
+
144
+
145
+ def _end_line(node: "tree_sitter.Node") -> int:
146
+ return node.end_point[0] + 1
147
+
148
+
149
+ def _walk(
150
+ node: "tree_sitter.Node",
151
+ on_enter,
152
+ parent_name: str | None = None,
153
+ ) -> None:
154
+ """Depth-first traversal delegating per-node logic to on_enter(node, parent_name).
155
+
156
+ on_enter returns the new `parent_name` to use for children (or None to
157
+ propagate the incoming one).
158
+ """
159
+ next_parent = on_enter(node, parent_name)
160
+ new_parent = next_parent if next_parent is not None else parent_name
161
+ for child in node.named_children:
162
+ _walk(child, on_enter, new_parent)
163
+
164
+
165
+ def parse_file(content: bytes, file_path: str | Path) -> ParseResult | None:
166
+ """Parse a TS/TSX/JS source buffer and return extracted symbols + calls.
167
+
168
+ Returns None when tree-sitter isn't available or the extension is not
169
+ one we handle; the caller should fall back to regex in that case.
170
+ """
171
+ language = _language_for(file_path)
172
+ if language is None:
173
+ return None
174
+
175
+ import tree_sitter as ts
176
+
177
+ parser = ts.Parser(language)
178
+ tree = parser.parse(content)
179
+ root = tree.root_node
180
+
181
+ result = ParseResult()
182
+ result.had_errors = root.has_error
183
+
184
+ def on_enter(node, parent_name):
185
+ t = node.type
186
+ if t == "class_declaration":
187
+ # JS grammar uses `identifier` for class names; TS uses
188
+ # `type_identifier`. Try both so the parser works on both
189
+ # language variants without a grammar-specific branch.
190
+ name_node = _first_child_of_type(
191
+ node, "type_identifier"
192
+ ) or _first_child_of_type(node, "identifier")
193
+ name = _node_text(name_node) if name_node else "<anon>"
194
+ result.symbols.append(
195
+ ExtractedSymbol(
196
+ kind="Class",
197
+ name=name,
198
+ lineno=_line(node),
199
+ end_lineno=_end_line(node),
200
+ parent_name=parent_name,
201
+ )
202
+ )
203
+ return name
204
+
205
+ if t == "interface_declaration":
206
+ name_node = _first_child_of_type(node, "type_identifier")
207
+ name = _node_text(name_node) if name_node else "<anon>"
208
+ result.symbols.append(
209
+ ExtractedSymbol(
210
+ kind="Interface",
211
+ name=name,
212
+ lineno=_line(node),
213
+ end_lineno=_end_line(node),
214
+ parent_name=parent_name,
215
+ )
216
+ )
217
+ return name
218
+
219
+ if t == "enum_declaration":
220
+ name_node = _first_child_of_type(node, "identifier")
221
+ name = _node_text(name_node) if name_node else "<anon>"
222
+ result.symbols.append(
223
+ ExtractedSymbol(
224
+ kind="Enum",
225
+ name=name,
226
+ lineno=_line(node),
227
+ end_lineno=_end_line(node),
228
+ parent_name=parent_name,
229
+ )
230
+ )
231
+ return None
232
+
233
+ if t == "function_declaration":
234
+ name_node = _first_child_of_type(node, "identifier")
235
+ name = _node_text(name_node) if name_node else "<anon>"
236
+ is_async = any(c.type == "async" for c in node.children)
237
+ result.symbols.append(
238
+ ExtractedSymbol(
239
+ kind="Function",
240
+ name=name,
241
+ lineno=_line(node),
242
+ end_lineno=_end_line(node),
243
+ parent_name=parent_name,
244
+ is_async=is_async,
245
+ )
246
+ )
247
+ return name
248
+
249
+ if t == "method_definition":
250
+ name_node = _first_child_of_type(node, "property_identifier")
251
+ name = _node_text(name_node) if name_node else "<anon>"
252
+ # Detect get/set/static/async modifiers as leading sibling tokens.
253
+ accessor = None
254
+ is_async = False
255
+ is_static = False
256
+ for child in node.children:
257
+ tt = child.type
258
+ if tt == "get":
259
+ accessor = "get"
260
+ elif tt == "set":
261
+ accessor = "set"
262
+ elif tt == "async":
263
+ is_async = True
264
+ elif tt == "static":
265
+ is_static = True
266
+ result.symbols.append(
267
+ ExtractedSymbol(
268
+ kind="Method",
269
+ name=name,
270
+ lineno=_line(node),
271
+ end_lineno=_end_line(node),
272
+ parent_name=parent_name,
273
+ is_async=is_async,
274
+ is_static=is_static,
275
+ accessor=accessor,
276
+ )
277
+ )
278
+ return name
279
+
280
+ if t == "lexical_declaration":
281
+ # const foo = (...) => {...} | const foo = function(){}
282
+ for decl in node.named_children:
283
+ if decl.type != "variable_declarator":
284
+ continue
285
+ name_node = _first_child_of_type(decl, "identifier")
286
+ if not name_node:
287
+ continue
288
+ rhs = decl.named_children[-1] if decl.named_children else None
289
+ if rhs is None:
290
+ continue
291
+ if rhs.type in ("arrow_function", "function_expression"):
292
+ name = _node_text(name_node)
293
+ result.symbols.append(
294
+ ExtractedSymbol(
295
+ kind="Function",
296
+ name=name,
297
+ lineno=_line(decl),
298
+ end_lineno=_end_line(decl),
299
+ parent_name=parent_name,
300
+ is_async=any(c.type == "async" for c in rhs.children),
301
+ )
302
+ )
303
+ return None
304
+
305
+ if t == "import_statement":
306
+ source = _first_child_of_type(node, "string")
307
+ module = None
308
+ if source is not None:
309
+ txt = _node_text(source)
310
+ if len(txt) >= 2 and txt[0] in ("'", '"') and txt[-1] in ("'", '"'):
311
+ module = txt[1:-1]
312
+ imported_names: list[str] = []
313
+ clause = _first_child_of_type(node, "import_clause")
314
+ if clause is not None:
315
+ # default + named + namespace
316
+ for child in clause.named_children:
317
+ if child.type == "identifier":
318
+ imported_names.append(_node_text(child))
319
+ elif child.type == "named_imports":
320
+ for spec in child.named_children:
321
+ if spec.type == "import_specifier":
322
+ ident = _first_child_of_type(spec, "identifier")
323
+ if ident is not None:
324
+ imported_names.append(_node_text(ident))
325
+ elif child.type == "namespace_import":
326
+ ident = _first_child_of_type(child, "identifier")
327
+ if ident is not None:
328
+ imported_names.append(_node_text(ident))
329
+ result.symbols.append(
330
+ ExtractedSymbol(
331
+ kind="Import",
332
+ name=module or "<unknown>",
333
+ lineno=_line(node),
334
+ end_lineno=_end_line(node),
335
+ parent_name=parent_name,
336
+ module=module,
337
+ imported_names=imported_names,
338
+ )
339
+ )
340
+ return None
341
+
342
+ if t == "call_expression":
343
+ callee_node = node.named_children[0] if node.named_children else None
344
+ if callee_node is None:
345
+ return None
346
+ if callee_node.type == "identifier":
347
+ result.calls.append((_line(node), _node_text(callee_node)))
348
+ elif callee_node.type == "member_expression":
349
+ # a.b.c(...) -> "a.b.c"
350
+ result.calls.append((_line(node), _node_text(callee_node)))
351
+ return None
352
+
353
+ return None
354
+
355
+ _walk(root, on_enter)
356
+ return result
descry/web/__init__.py ADDED
File without changes