leyline-code 0.8.1__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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,1026 @@
1
+ """C# adapter: tree-sitter structure, with enough local type inference to resolve most calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+ import tree_sitter_c_sharp
9
+ from tree_sitter import Language, Parser
10
+
11
+ from .. import channels
12
+ from ..model import FieldUse, CallSite, Edge, Endpoint, EventUse, FileResult, ImportRef, Node, TypeRef
13
+
14
+ NAME = "tree-sitter-c-sharp"
15
+ VERSION = "0.1"
16
+ LANGUAGE = "csharp"
17
+ EXTENSIONS = (".cs",)
18
+
19
+ _parser = Parser(Language(tree_sitter_c_sharp.language()))
20
+
21
+ TYPE_DECLS = {
22
+ "class_declaration": "class",
23
+ "struct_declaration": "struct",
24
+ "interface_declaration": "interface",
25
+ "enum_declaration": "enum",
26
+ "record_declaration": "record",
27
+ "record_struct_declaration": "record",
28
+ "delegate_declaration": "delegate",
29
+ }
30
+ CALLABLE_DECLS = {
31
+ "method_declaration", "constructor_declaration", "destructor_declaration",
32
+ "operator_declaration", "conversion_operator_declaration", "local_function_statement",
33
+ }
34
+ ACCESS = ("public", "private", "protected", "internal")
35
+ LAMBDAS = ("lambda_expression", "anonymous_method_expression")
36
+ # `this` and `base` as the grammar names them: this_expression in older releases, this from 0.23 on.
37
+ THIS = ("this_expression", "this")
38
+ BASE = ("base_expression", "base")
39
+
40
+
41
+ LITERALS = {"integer_literal": "int", "real_literal": "double", "boolean_literal": "bool", "character_literal": "char",
42
+ "string_literal": "string", "verbatim_string_literal": "string", "raw_string_literal": "string",
43
+ "interpolated_string_expression": "string"}
44
+
45
+
46
+ HTTP_CLIENT = {"GetAsync": "GET", "GetStringAsync": "GET", "GetFromJsonAsync": "GET", "GetStreamAsync": "GET",
47
+ "GetByteArrayAsync": "GET", "PostAsync": "POST", "PostAsJsonAsync": "POST", "PutAsync": "PUT",
48
+ "PutAsJsonAsync": "PUT", "DeleteAsync": "DELETE", "PatchAsync": "PATCH"}
49
+ HTTP_MAP = {"MapGet": "GET", "MapPost": "POST", "MapPut": "PUT", "MapDelete": "DELETE", "MapPatch": "PATCH"}
50
+ # An attribute with a template, first in its list or after another: [HttpGet("x")], [Authorize, HttpPost("x")].
51
+ HTTP_ATTR = re.compile(r"[\[,]\s*(Http(Get|Post|Put|Delete|Patch)|Route)\s*\(\s*\"([^\"]*)\"")
52
+ HTTP_BARE = re.compile(r"\[\s*(?:[^\]]*,\s*)?Http(Get|Post|Put|Delete|Patch)\s*(?:\(\s*\)\s*)?[\],]")
53
+ FILE_CALL = re.compile(r"(?:^|\.)(File|Directory|FileAccess)\.(\w+)$")
54
+
55
+
56
+ def _cs_strings(node) -> list[str]:
57
+ """String literals under a node. An interpolated hole becomes {}."""
58
+ out, stack = [], [node]
59
+ while stack:
60
+ n = stack.pop()
61
+ if n.type == "interpolated_string_expression":
62
+ parts = []
63
+ for c in n.children:
64
+ if c.type == "interpolation":
65
+ parts.append("{}")
66
+ elif c.is_named and not c.type.startswith("interpolation_"): # not the $ that opens it
67
+ parts.append(_text(c))
68
+ out.append("".join(parts))
69
+ elif n.type in ("string_literal", "verbatim_string_literal", "raw_string_literal"):
70
+ body = "".join(_text(c) for c in n.children if c.type == "string_literal_content" or c.type.endswith("content"))
71
+ out.append(body or _text(n).lstrip("@").strip('"'))
72
+ else:
73
+ stack.extend(reversed(n.children))
74
+ return [x for x in out if x]
75
+
76
+
77
+ # Calls whose first string is never a request: logging, string work, routing and parsing.
78
+ NOT_REQUESTS = frozenset("""WriteLine Write Log LogInformation LogDebug LogWarning LogError LogTrace LogCritical Format
79
+ Concat Join Split Replace StartsWith EndsWith Contains IndexOf Equals Compare Parse TryParse Match IsMatch Matches
80
+ Combine GetFullPath Exists Map MapGroup MapFallback UseRouting Route Redirect RedirectPermanent Throw Assert
81
+ Created CreatedAtRoute CreatedAtAction Accepted AcceptedAtRoute LocalRedirect AddRoute MapForwarder""".split())
82
+
83
+
84
+ def _relative_url(path: str):
85
+ """A request path with no leading slash, said against a client's base address: "api/items/1" -> /api/items/1.
86
+ None for anything that is not plainly a path of two or more parts."""
87
+ path = path[2:] if path.startswith("{}/") else path
88
+ path = path.split("?", 1)[0]
89
+ if not path or path.startswith(("http:", "https:")) or any(ch.isspace() for ch in path):
90
+ return None
91
+ parts = [p for p in path.split("/") if p]
92
+ if len(parts) < 2 or "." in parts[0] or "." in parts[-1] or not re.fullmatch(r"[\w\-{}:]+", parts[0]):
93
+ return None
94
+ return "/" + "/".join(parts)
95
+
96
+
97
+ def _controller_prefix(method_node, method_name: str):
98
+ """The route an ASP.NET controller's [Route("api/[controller]")] gives its actions, or None without one."""
99
+ cls = method_node.parent
100
+ while cls is not None and cls.type not in ("class_declaration", "record_declaration"):
101
+ if cls.type in CALLABLE_DECLS or cls.type == "compilation_unit":
102
+ return None
103
+ cls = cls.parent
104
+ if cls is None:
105
+ return None
106
+ text = " ".join(_text(a) for a in cls.children if a.type == "attribute_list")
107
+ m = re.search(r"\bRoute\s*\(\s*@?\"([^\"]*)\"", text)
108
+ if not m:
109
+ return None
110
+ cname = _text(cls.child_by_field_name("name"))
111
+ cname = cname[:-len("Controller")] if cname.endswith("Controller") and len(cname) > len("Controller") else cname
112
+ return m.group(1).replace("[controller]", cname).replace("[action]", method_name).strip("/")
113
+
114
+
115
+ def pathlike(s: str) -> bool:
116
+ if not s or len(s) > 120 or any(ch.isspace() for ch in s) or s.startswith(("http:", "https:")):
117
+ return False
118
+ return bool(re.fullmatch(r"(\{\}|\*)?(\.[A-Za-z0-9_]{1,10}){1,2}", s)) or "/" in s.strip("/")
119
+
120
+
121
+ def _identifiers(node) -> list[str]:
122
+ if node is None:
123
+ return []
124
+ if node.type == "identifier":
125
+ return [_text(node)]
126
+ return [x for c in node.children for x in _identifiers(c)]
127
+
128
+
129
+ def _declared_names(node) -> list[str]:
130
+ """Names a lambda, pattern, catch clause or out-declaration introduces."""
131
+ if node.type == "lambda_expression":
132
+ first = node.child_by_field_name("parameters") or (node.named_children[0] if node.named_children else None)
133
+ if first is None or first.type not in ("implicit_parameter", "parameter_list", "identifier"):
134
+ return []
135
+ if first.type == "parameter_list":
136
+ return [_text(p.child_by_field_name("name") or p.named_children[-1]) for p in first.children if p.type == "parameter"]
137
+ return [_text(first)]
138
+ name = node.child_by_field_name("name")
139
+ if name is not None:
140
+ return _identifiers(name)
141
+ ids = [c for c in node.named_children
142
+ if c.type in ("identifier", "single_variable_designation", "parenthesized_variable_designation")]
143
+ return _identifiers(ids[-1]) if ids else []
144
+
145
+
146
+ def _predefined(tnode) -> str:
147
+ """int, float, string: a parameter of one of these is matched against a literal or a variable of it."""
148
+ return _text(tnode) if tnode is not None and tnode.type == "predefined_type" else ""
149
+
150
+
151
+ def _product(node, scope: dict) -> Optional[tuple]:
152
+ """`total * share` as an argument parses as a declaration of a pointer variable. When the "type" is a local
153
+ variable, it is a multiplication: the two names."""
154
+ if node.type != "declaration_expression":
155
+ return None
156
+ tnode, name = node.child_by_field_name("type"), node.child_by_field_name("name")
157
+ inner = tnode.named_children[0] if tnode is not None and tnode.type == "pointer_type" and tnode.named_children else None
158
+ if inner is None or name is None or inner.type != "identifier" or ("#" + _text(inner)) not in scope:
159
+ return None
160
+ return _text(inner), _text(name)
161
+
162
+
163
+ def _arg_hint(arg, scope: dict):
164
+ """What is known about one argument: a lambda's parameter count (int), a type name (str), or None."""
165
+ if arg.type == "lambda_expression":
166
+ params = arg.child_by_field_name("parameters")
167
+ if params is None:
168
+ return None
169
+ if params.type in ("identifier", "implicit_parameter"):
170
+ return 1
171
+ return len([c for c in params.children if c.type in ("parameter", "identifier", "implicit_parameter")])
172
+ if arg.type == "real_literal":
173
+ return {"f": "float", "m": "decimal"}.get(_text(arg)[-1:].lower(), "double") # 9f is a float, 9.0 a double
174
+ if arg.type in LITERALS:
175
+ return LITERALS[arg.type]
176
+ if arg.type == "parenthesized_expression" and arg.named_children:
177
+ return _arg_hint(arg.named_children[0], scope)
178
+ if _product(arg, scope):
179
+ sides = [scope.get("%" + x) for x in _product(arg, scope)]
180
+ return next((wide for wide in NUMERIC if wide in sides), None) if all(x in NUMERIC for x in sides) else None
181
+ if arg.type == "binary_expression":
182
+ # total * share: arithmetic on numbers has the widest operand's type; on a string, + gives a string.
183
+ op = arg.child_by_field_name("operator")
184
+ left, right = arg.child_by_field_name("left"), arg.child_by_field_name("right")
185
+ if op is None or left is None or right is None or _text(op) not in ("+", "-", "*", "/", "%"):
186
+ return None
187
+ sides = [_arg_hint(left, scope), _arg_hint(right, scope)]
188
+ if _text(op) == "+" and "string" in sides:
189
+ return "string"
190
+ for wide in NUMERIC:
191
+ if wide in sides:
192
+ return wide if all(x in NUMERIC for x in sides) else None
193
+ return None
194
+ if arg.type == "object_creation_expression":
195
+ names = _type_names(arg.child_by_field_name("type"))
196
+ return names[0].split("`")[0] if names else None
197
+ if arg.type == "identifier":
198
+ known = scope.get(_text(arg)) or scope.get("%" + _text(arg))
199
+ return known.split("`")[0] if known else None
200
+ return None
201
+
202
+
203
+ NUMERIC = ("decimal", "double", "float", "ulong", "long", "uint", "int") # widest first
204
+
205
+
206
+ PREDEFINED = {"int", "long", "short", "byte", "uint", "ulong", "float", "double", "decimal", "bool", "string",
207
+ "char", "TimeSpan", "DateTime", "DateTimeOffset", "Guid", "CancellationToken"}
208
+
209
+
210
+ def _delegate_arity(ptype: str) -> int:
211
+ """How many arguments a delegate-typed parameter takes; -1 when the type is not a known delegate."""
212
+ head, _, rest = ptype.rstrip("?").partition("<")
213
+ head = head.rsplit(".", 1)[-1]
214
+ if head in PREDEFINED:
215
+ return -2 # certainly not a delegate: a lambda cannot be passed here
216
+ if head not in ("Action", "Func", "Predicate", "Comparison"):
217
+ return -1
218
+ if not rest:
219
+ return 0
220
+ depth, n = 0, 1
221
+ for ch in rest[:-1]:
222
+ depth += ch == "<"
223
+ depth -= ch == ">"
224
+ n += ch == "," and depth == 0
225
+ return {"Action": n, "Func": n - 1, "Predicate": 1, "Comparison": 2}[head]
226
+ STRINGS = ("string_literal", "verbatim_string_literal", "raw_string_literal", "interpolated_string_expression")
227
+ # Calls of the shape Run("name", () => { ... }) declare a test inline.
228
+ TEST_RUNNERS = {"Run", "Test", "It", "Case", "Scenario", "Fact", "Check", "Spec"}
229
+ TEST_ATTRIBUTES = {"Fact", "Theory", "Test", "TestCase", "TestMethod", "DataTestMethod"}
230
+ LIFECYCLE = {"_Ready", "_Process", "_PhysicsProcess", "_Input", "_UnhandledInput", "_EnterTree",
231
+ "_ExitTree", "_Draw", "_Notification"}
232
+
233
+
234
+ def _text(node) -> str:
235
+ return node.text.decode("utf8", "replace") if node is not None else ""
236
+
237
+
238
+ def _squash(s: str) -> str:
239
+ return re.sub(r"\s+", " ", s).strip()
240
+
241
+
242
+ def _modifiers(node) -> list[str]:
243
+ return [_text(c) for c in node.children if c.type == "modifier"]
244
+
245
+
246
+ def _visibility(mods: list[str], default: str) -> str:
247
+ found = [m for m in mods if m in ACCESS]
248
+ return " ".join(found) if found else default
249
+
250
+
251
+ def _child(node, *types):
252
+ for c in node.children:
253
+ if c.type in types:
254
+ return c
255
+ return None
256
+
257
+
258
+ def _type_names(node) -> list[str]:
259
+ """Every identifier in a type expression: List<Foo> -> [List, Foo]."""
260
+ out: list[str] = []
261
+ if node is None:
262
+ return out
263
+ stack = [node]
264
+ while stack:
265
+ n = stack.pop()
266
+ if n.type == "identifier":
267
+ out.append(_text(n))
268
+ elif n.type == "generic_name":
269
+ # Foo<A, B> is written Foo`2, so it is told apart from a non-generic Foo.
270
+ targs = _child(n, "type_argument_list")
271
+ count = len([c for c in targs.named_children]) if targs is not None else 0
272
+ out.append(f"{_text(_child(n, 'identifier'))}`{count}" if count else _text(_child(n, "identifier")))
273
+ if targs is not None:
274
+ stack.extend(reversed(targs.children))
275
+ elif n.type == "qualified_name":
276
+ # A.B.C: the last segment is the type; keep it only.
277
+ ids = [c for c in n.children if c.type in ("identifier", "generic_name")]
278
+ if ids:
279
+ stack.append(ids[-1])
280
+ else:
281
+ stack.extend(reversed(n.children))
282
+ return out
283
+
284
+
285
+ SEQUENCES = {"List", "IList", "IEnumerable", "IReadOnlyList", "ICollection", "IReadOnlyCollection",
286
+ "HashSet", "ISet", "Queue", "Stack", "LinkedList", "SortedSet", "ImmutableArray",
287
+ "ImmutableList", "Span", "ReadOnlySpan"}
288
+
289
+
290
+ def _elem_type(node) -> Optional[str]:
291
+ """Element type of a sequence or array type expression: List<Foo> and Foo[] both give Foo."""
292
+ if node is None:
293
+ return None
294
+ names = _type_names(node)
295
+ if not names:
296
+ return None
297
+ text = _text(node)
298
+ if text.rstrip("?").endswith("]"):
299
+ return names[0]
300
+ if len(names) == 2 and names[0].split("`")[0] in SEQUENCES:
301
+ return names[1]
302
+ return None
303
+
304
+
305
+ def _outer_type(node) -> Optional[str]:
306
+ names = _type_names(node)
307
+ return names[0] if names else None
308
+
309
+
310
+ # Methods that change the collection they are called on. A field used this way is written, not only read.
311
+ MUTATORS = frozenset("""Add AddRange AddFirst AddLast Insert InsertRange Remove RemoveAt RemoveAll RemoveRange RemoveFirst
312
+ RemoveLast RemoveWhere Clear Enqueue Dequeue TryDequeue Push Pop TryPop TryAdd TryRemove TryUpdate TryTake Sort Reverse
313
+ UnionWith ExceptWith IntersectWith SymmetricExceptWith Append AppendLine AppendFormat Set SetValue Fill EnsureCapacity
314
+ TrimExcess GetOrAdd AddOrUpdate""".split())
315
+
316
+
317
+ class _Walker:
318
+ def __init__(self, repo: str, rel_path: str, file_id: str, src: bytes, module: str):
319
+ self.repo = repo
320
+ self.path = rel_path
321
+ self.file_id = file_id
322
+ self.module = module
323
+ self.res = FileResult()
324
+ self.src = src # spans are offsets into the file; the root node starts after a byte order mark
325
+ self.tree = _parser.parse(src)
326
+ self.field_types: dict[str, dict[str, str]] = {} # type id -> member name -> type name
327
+ self.redirect: dict[tuple, str] = {} # lambda span -> node its body's calls belong to
328
+ self.done_calls: dict[tuple, Optional[CallSite]] = {} # invocation span -> its call site
329
+
330
+ # -- ids -----------------------------------------------------------------
331
+ def _qid(self, qualified: str) -> str:
332
+ # The assembly is part of a .NET type's identity: two projects may both declare `Program`.
333
+ return f"{self.repo}:csharp:{self.module}::{qualified}"
334
+
335
+ # -- top level -----------------------------------------------------------
336
+ def run(self) -> FileResult:
337
+ root = self.tree.root_node
338
+ globals_ = [c for c in root.children if c.type == "global_statement"]
339
+ self._members(root, ns="", parent_id=self.file_id, type_id=None, qual="")
340
+ if globals_:
341
+ cid = self._qid(f"{self.path}::<top-level>")
342
+ self.res.nodes.append(Node(
343
+ id=cid, kind="callable", name="<top-level>", parent_id=self.file_id,
344
+ language=LANGUAGE, path=self.path, span_start=globals_[0].start_point[0] + 1,
345
+ span_end=globals_[-1].end_point[0] + 1,
346
+ attrs={"signature": "top-level statements", "visibility": "internal",
347
+ "native_kind": "top_level"}))
348
+ self.res.nodes.append(Node(
349
+ id=cid + "#entry", kind="entry_point", name=f"{self.path} (top-level)",
350
+ parent_id=self.file_id, language=LANGUAGE, path=self.path,
351
+ attrs={"trigger": "cli", "address": self.path}))
352
+ self.res.edges.append(Edge("exposes", cid + "#entry", cid))
353
+ scope: dict[str, str] = {}
354
+ for g in globals_:
355
+ self._body(g, cid, None, scope)
356
+ return self.res
357
+
358
+ def _members(self, node, ns: str, parent_id: str, type_id: Optional[str], qual: str) -> None:
359
+ for c in node.children:
360
+ t = c.type
361
+ if t == "using_directive":
362
+ self._using(c)
363
+ elif t in ("namespace_declaration", "file_scoped_namespace_declaration"):
364
+ name = _text(c.child_by_field_name("name"))
365
+ full = f"{ns}.{name}" if ns else name
366
+ if full not in self.res.declares:
367
+ self.res.declares.append(full)
368
+ body = c.child_by_field_name("body") or c
369
+ self._members(body, full, parent_id, type_id, full)
370
+ if t == "file_scoped_namespace_declaration":
371
+ # `namespace X;` covers the rest of the file: what follows is a sibling, not a child.
372
+ ns = qual = full
373
+ elif t in TYPE_DECLS:
374
+ self._type(c, ns, parent_id, qual)
375
+ elif t == "declaration_list":
376
+ self._members(c, ns, parent_id, type_id, qual)
377
+
378
+ def _using(self, node) -> None:
379
+ raw = _squash(_text(node)).rstrip(";")
380
+ raw = re.sub(r"^(global\s+)?using\s+", "", raw)
381
+ is_static = raw.startswith("static ")
382
+ raw = raw[7:] if is_static else raw
383
+ alias = None
384
+ if "=" in raw:
385
+ alias, raw = [p.strip() for p in raw.split("=", 1)]
386
+ self.res.imports.append(ImportRef(self.file_id, raw, alias=alias, is_static=is_static))
387
+
388
+ # -- types ---------------------------------------------------------------
389
+ def _type(self, node, ns: str, parent_id: str, qual: str) -> None:
390
+ native = TYPE_DECLS[node.type]
391
+ name = _text(node.child_by_field_name("name"))
392
+ tparams = _child(node, "type_parameter_list")
393
+ arity = len([c for c in tparams.children if c.type == "type_parameter"]) if tparams else 0
394
+ qname = f"{qual}.{name}" if qual else name
395
+ if arity:
396
+ qname += f"`{arity}"
397
+ tid = self._qid(qname)
398
+ mods = _modifiers(node)
399
+ body = node.child_by_field_name("body") or _child(node, "declaration_list", "enum_member_declaration_list")
400
+ head_end = body.start_byte if body is not None else node.end_byte
401
+ self.res.nodes.append(Node(
402
+ id=tid, kind="type", name=name, parent_id=parent_id, language=LANGUAGE, path=self.path,
403
+ span_start=node.start_point[0] + 1, span_end=node.end_point[0] + 1,
404
+ attrs={"native_kind": native, "namespace": ns,
405
+ "visibility": _visibility(mods, "internal" if parent_id == self.file_id else "private"),
406
+ "is_abstract": "abstract" in mods or native == "interface",
407
+ "is_static": "static" in mods, "is_partial": "partial" in mods,
408
+ "signature": _squash(self.src[node.start_byte:head_end].decode("utf8", "replace"))}))
409
+ base_list = _child(node, "base_list")
410
+ if base_list is not None:
411
+ for b in base_list.children:
412
+ if b.type in (",", ":"):
413
+ continue
414
+ names = _type_names(b)
415
+ if names:
416
+ self.res.type_refs.append(TypeRef(tid, names, "base", b.start_point[0] + 1))
417
+ self.field_types.setdefault(tid, {})
418
+ if native == "delegate":
419
+ return
420
+ # Record primary-constructor parameters as fields (records), with a body or without: record Item(int Id);
421
+ # A class's or struct's (C# 12: class Svc(ILogger logger)) are parameters it captures, not properties.
422
+ plist = _child(node, "parameter_list")
423
+ if plist is not None and native == "record":
424
+ for p in plist.children:
425
+ if p.type == "parameter":
426
+ self._field_node(tid, _text(p.child_by_field_name("name")), p.child_by_field_name("type"),
427
+ p, ["public"], "property")
428
+ if body is None:
429
+ return
430
+ for m in body.children:
431
+ mt = m.type
432
+ if mt in TYPE_DECLS:
433
+ self._type(m, ns, tid, qname)
434
+ elif mt == "field_declaration" or mt == "event_field_declaration":
435
+ decl = _child(m, "variable_declaration")
436
+ if decl is None:
437
+ continue
438
+ tnode = decl.child_by_field_name("type")
439
+ for v in decl.children:
440
+ if v.type == "variable_declarator":
441
+ nm = v.child_by_field_name("name") or _child(v, "identifier")
442
+ self._field_node(tid, _text(nm), tnode, m, _modifiers(m),
443
+ "event" if mt.startswith("event") else "field")
444
+ elif mt in ("property_declaration", "indexer_declaration", "event_declaration"):
445
+ nm = m.child_by_field_name("name")
446
+ self._field_node(tid, _text(nm) if nm else "this[]", m.child_by_field_name("type"), m,
447
+ _modifiers(m), mt.split("_")[0])
448
+ elif mt == "enum_member_declaration":
449
+ nm = m.child_by_field_name("name") or _child(m, "identifier")
450
+ self.res.nodes.append(Node(
451
+ id=f"{tid}.{_text(nm)}", kind="field", name=_text(nm), parent_id=tid,
452
+ language=LANGUAGE, path=self.path, span_start=m.start_point[0] + 1,
453
+ span_end=m.end_point[0] + 1,
454
+ attrs={"native_kind": "enum_member", "visibility": "public", "is_mutable": False}))
455
+ # Callables second, so every field type of this type is known to the body walk.
456
+ for m in body.children:
457
+ if m.type in CALLABLE_DECLS:
458
+ self._callable(m, tid, tid, native == "interface")
459
+ elif m.type in ("property_declaration", "indexer_declaration"):
460
+ # Property bodies: attribute their calls to the property itself.
461
+ nm = m.child_by_field_name("name")
462
+ pid = f"{tid}.{_text(nm) if nm else 'this[]'}"
463
+ self._body(m, pid, tid, {})
464
+
465
+ def _field_node(self, tid, name, tnode, node, mods, native) -> None:
466
+ if not name:
467
+ return
468
+ default_vis = "private"
469
+ self.res.nodes.append(Node(
470
+ id=f"{tid}.{name}", kind="field", name=name, parent_id=tid, language=LANGUAGE,
471
+ path=self.path, span_start=node.start_point[0] + 1, span_end=node.end_point[0] + 1,
472
+ attrs={"native_kind": native, "declared_type": _squash(_text(tnode)),
473
+ "type_name": _outer_type(tnode),
474
+ "visibility": _visibility(mods, default_vis), "is_static": "static" in mods or "const" in mods,
475
+ "is_mutable": not ("readonly" in mods or "const" in mods)}))
476
+ self.res.edges.append(Edge("has_field", tid, f"{tid}.{name}"))
477
+ names = _type_names(tnode)
478
+ if names:
479
+ self.res.type_refs.append(TypeRef(f"{tid}.{name}", names, "field_type", node.start_point[0] + 1))
480
+ self.field_types.setdefault(tid, {})[name] = names[0]
481
+ elem = _elem_type(tnode)
482
+ if elem:
483
+ self.field_types[tid][name + "[]"] = elem
484
+
485
+ # -- callables -----------------------------------------------------------
486
+ def _callable(self, node, parent_id: str, type_id: Optional[str], in_interface: bool,
487
+ outer_scope: Optional[dict] = None) -> None:
488
+ t = node.type
489
+ if t == "constructor_declaration":
490
+ name = ".ctor"
491
+ elif t == "destructor_declaration":
492
+ name = ".dtor"
493
+ elif t in ("operator_declaration", "conversion_operator_declaration"):
494
+ op = node.child_by_field_name("operator")
495
+ name = "operator " + (_text(op) if op else _squash(_text(node.child_by_field_name("type"))))
496
+ else:
497
+ name = _text(node.child_by_field_name("name"))
498
+ params = node.child_by_field_name("parameters") or _child(node, "parameter_list")
499
+ scope: dict[str, str] = dict(outer_scope or {})
500
+ ptypes: list[str] = []
501
+ required = 0
502
+ plist = [p for p in params.children if p.type == "parameter"] if params is not None else []
503
+ for p in plist:
504
+ ptype = p.child_by_field_name("type")
505
+ ptypes.append(_squash(_text(ptype)).replace(", ", ","))
506
+ pname = _text(p.child_by_field_name("name"))
507
+ outer = _outer_type(ptype)
508
+ if pname:
509
+ scope["#" + pname] = "1" # a local name: never a field of the enclosing type
510
+ scope.pop("%" + pname, None)
511
+ if ptype is not None and ptype.type == "predefined_type":
512
+ scope["%" + pname] = _text(ptype) # float, int: not a receiver, but tells overloads apart
513
+ if pname and outer:
514
+ scope[pname] = outer
515
+ elem = _elem_type(ptype)
516
+ if elem:
517
+ scope[pname + "[]"] = elem
518
+ has_default = any(c.type in ("equals_value_clause", "=") for c in p.children)
519
+ is_params = any(_text(c) == "params" for c in p.children)
520
+ if not has_default and not is_params:
521
+ required += 1
522
+ sep = "/" if t == "local_function_statement" else "."
523
+ tparams = _child(node, "type_parameter_list")
524
+ generic_arity = len([c for c in tparams.children if c.type == "type_parameter"]) if tparams is not None else 0
525
+ # M(int) and M<T>(int) are different methods, so the count of type parameters is part of the id.
526
+ cid = f"{parent_id}{sep}{name}{'`' + str(generic_arity) if generic_arity else ''}({','.join(ptypes)})"
527
+ mods = _modifiers(node)
528
+ body = node.child_by_field_name("body") or _child(node, "block", "arrow_expression_clause")
529
+ head_end = body.start_byte if body is not None else node.end_byte
530
+ ret = node.child_by_field_name("returns") or node.child_by_field_name("type")
531
+ is_variadic = any(any(_text(c) == "params" for c in p.children) for p in plist)
532
+ self.res.nodes.append(Node(
533
+ id=cid, kind="callable", name=name, parent_id=parent_id, language=LANGUAGE, path=self.path,
534
+ span_start=node.start_point[0] + 1, span_end=node.end_point[0] + 1,
535
+ attrs={"signature": _squash(self.src[node.start_byte:head_end].decode("utf8", "replace")).rstrip(";"),
536
+ "visibility": "public" if in_interface else _visibility(mods, "private"),
537
+ "is_static": "static" in mods, "is_async": "async" in mods,
538
+ "is_virtual": in_interface or any(m in mods for m in ("virtual", "abstract", "override")),
539
+ "native_kind": t.replace("_declaration", "").replace("_statement", ""),
540
+ "argc_min": required, "argc_max": 99 if is_variadic else len(plist),
541
+ "body_line": body.start_point[0] + 1 if body is not None else None,
542
+ "delegate_arity": [_delegate_arity(x) for x in ptypes], "generic_arity": generic_arity,
543
+ "returns_names": _type_names(ret) if ret is not None and t != "constructor_declaration" else [],
544
+ "type_params": [_text(_child(c, "identifier") or c) for c in tparams.children
545
+ if c.type == "type_parameter"] if tparams is not None else [],
546
+ "is_extension": bool(plist) and any(_text(c) == "this" for c in plist[0].children) or None,
547
+ "param_types": [(_type_names(p.child_by_field_name("type")) or [_predefined(p.child_by_field_name("type"))])[0]
548
+ for p in plist],
549
+ "type_id": type_id}))
550
+ for p, ptext in zip(plist, ptypes):
551
+ names = _type_names(p.child_by_field_name("type"))
552
+ if names:
553
+ self.res.type_refs.append(TypeRef(cid, names, "param", p.start_point[0] + 1))
554
+ if ret is not None:
555
+ names = _type_names(ret)
556
+ if names:
557
+ self.res.type_refs.append(TypeRef(cid, names, "return", ret.start_point[0] + 1))
558
+ attrs_text = " ".join(_text(a) for a in node.children if a.type == "attribute_list")
559
+ prefix = _controller_prefix(node, name)
560
+ # [HttpGet] with no template: the verb of the action's own [Route("...")], else of the controller's route.
561
+ bare = [m.group(1).upper() for m in HTTP_BARE.finditer(attrs_text)]
562
+ routed = False
563
+ for m in HTTP_ATTR.finditer(attrs_text):
564
+ path = m.group(3)
565
+ if prefix is not None and not path.startswith(("/", "~")): # [Route("api/[controller]")] on the class
566
+ path = f"{prefix}/{path}" if path else prefix
567
+ path = path.lstrip("~")
568
+ routed = routed or not m.group(2)
569
+ for method in [m.group(2).upper()] if m.group(2) else bare or [None]:
570
+ self.res.endpoints.append(Endpoint("http", "serve", cid, "/" + path.lstrip("/"), node.start_point[0] + 1,
571
+ method))
572
+ if prefix is not None and not routed: # the action answers at the controller's own route
573
+ for method in bare:
574
+ self.res.endpoints.append(Endpoint("http", "serve", cid, "/" + prefix.lstrip("/"), node.start_point[0] + 1,
575
+ method))
576
+ # The attributes' own names ([Xunit.Fact], [TestCase(1)]), not words in their arguments ([Description("Test")]).
577
+ found = {re.sub(r"Attribute$", "", _text(a.child_by_field_name("name")).rsplit(".", 1)[-1])
578
+ for al in node.children if al.type == "attribute_list"
579
+ for a in al.named_children if a.type == "attribute"} & TEST_ATTRIBUTES
580
+ if found:
581
+ self.res.nodes[-1].attrs["is_test"] = True
582
+ self.res.nodes[-1].attrs["framework"] = sorted(found)[0]
583
+ if name == "Main" and "static" in mods:
584
+ self._entry(cid, "cli", self.path)
585
+ elif name in LIFECYCLE:
586
+ self._entry(cid, "lifecycle", name)
587
+ if body is not None:
588
+ self._body(body, cid, type_id, scope)
589
+ init = _child(node, "constructor_initializer")
590
+ if init is not None:
591
+ self._body(init, cid, type_id, scope)
592
+
593
+ def _entry(self, cid: str, trigger: str, address: str) -> None:
594
+ self.res.nodes.append(Node(
595
+ id=cid + "#entry", kind="entry_point", name=cid.split(":", 2)[-1], parent_id=self.file_id,
596
+ language=LANGUAGE, path=self.path, attrs={"trigger": trigger, "address": address}))
597
+ self.res.edges.append(Edge("exposes", cid + "#entry", cid))
598
+
599
+ # -- bodies --------------------------------------------------------------
600
+ def _body(self, node, cid: str, type_id: Optional[str], scope: dict[str, str]) -> None:
601
+ """Walk a body in source order, tracking local variable types and emitting call sites."""
602
+ t = node.type
603
+ if t in LAMBDAS and self.redirect:
604
+ cid = self.redirect.pop((node.start_byte, node.end_byte), cid)
605
+ if t == "local_function_statement":
606
+ self._callable(node, cid, type_id, False, scope)
607
+ return
608
+ if t in TYPE_DECLS:
609
+ return
610
+ if t == "invocation_expression" and _text(node.child_by_field_name("function")) == "nameof":
611
+ return # nameof(_repo) is the name, read when compiling: nothing in it runs
612
+ if t == "variable_declaration":
613
+ tnode = node.child_by_field_name("type")
614
+ declared = _outer_type(tnode) if tnode is not None and _text(tnode) != "var" else None
615
+ if declared:
616
+ self.res.type_refs.append(TypeRef(cid, _type_names(tnode), "local", node.start_point[0] + 1))
617
+ for v in node.children:
618
+ if v.type != "variable_declarator":
619
+ continue
620
+ nm = _text(v.child_by_field_name("name") or _child(v, "identifier"))
621
+ vt = declared or self._infer(v, scope, type_id)
622
+ if nm:
623
+ scope.pop("~" + nm, None)
624
+ scope.pop("%" + nm, None)
625
+ scope["#" + nm] = "1"
626
+ if tnode is not None and tnode.type == "predefined_type":
627
+ scope["%" + nm] = _text(tnode)
628
+ if nm and vt:
629
+ scope[nm] = vt
630
+ elif nm:
631
+ # `var x = a.Make();`: the type is whatever Make returns, known once calls are resolved.
632
+ init = self._call_in(v)
633
+ if init is not None:
634
+ site = self._invocation(init, cid, type_id, scope)
635
+ if site is not None:
636
+ scope["~" + nm] = site
637
+ elem = _elem_type(tnode) if declared else self._infer(v, scope, type_id, elem=True)
638
+ if nm and elem:
639
+ scope[nm + "[]"] = elem
640
+ elif t in ("foreach_statement",):
641
+ tnode = node.child_by_field_name("type")
642
+ nm = _text(node.child_by_field_name("left"))
643
+ for ident in _identifiers(node.child_by_field_name("left")):
644
+ scope["#" + ident] = "1"
645
+ if tnode is not None and _text(tnode) != "var" and nm:
646
+ outer = _outer_type(tnode)
647
+ if outer:
648
+ scope[nm] = outer
649
+ elif nm:
650
+ elem = self._elem_of(node.child_by_field_name("right"), scope, type_id)
651
+ if elem:
652
+ scope[nm] = elem
653
+ elif t == "invocation_expression":
654
+ self._invocation(node, cid, type_id, scope)
655
+ elif t in ("lambda_expression", "catch_declaration", "declaration_expression", "declaration_pattern", "from_clause") \
656
+ and not _product(node, scope):
657
+ tnode = node.child_by_field_name("type") if t != "lambda_expression" else None
658
+ declared = _outer_type(tnode) if tnode is not None and _text(tnode) != "var" else None
659
+ for ident in _declared_names(node): # names these introduce are locals
660
+ scope["#" + ident] = "1"
661
+ if declared: # `x is Sandbox s`, `out Foo f`, `catch (IOException e)`: typed where introduced
662
+ scope.pop("~" + ident, None)
663
+ scope[ident] = declared
664
+ if t in ("identifier", "member_access_expression"):
665
+ self._use(node, cid, type_id, scope)
666
+ elif t in STRINGS:
667
+ for text in _cs_strings(node):
668
+ if pathlike(text):
669
+ self.res.path_strings.setdefault(cid, []).append(text)
670
+ if t == "assignment_expression" and any(_text(c) == "+=" for c in node.children if not c.is_named):
671
+ left, right = node.child_by_field_name("left"), node.child_by_field_name("right")
672
+ if left is not None and right is not None and right.type in LAMBDAS + ("identifier", "member_access_expression"):
673
+ handler = None
674
+ if right.type == "identifier":
675
+ handler = _text(right)
676
+ elif right.type == "member_access_expression":
677
+ handler = _text(right.child_by_field_name("name"))
678
+ if left.type == "identifier":
679
+ self.res.events.append(EventUse("subscribe", cid, _text(left), None, None, handler,
680
+ node.start_point[0] + 1, type_id))
681
+ elif left.type == "member_access_expression":
682
+ recv, rtype = self._receiver(left.child_by_field_name("expression"), scope, type_id)
683
+ self.res.events.append(EventUse("subscribe", cid, _text(left.child_by_field_name("name")),
684
+ recv, rtype, handler, node.start_point[0] + 1, type_id))
685
+ elif t == "object_creation_expression":
686
+ tnode = node.child_by_field_name("type")
687
+ names = _type_names(tnode)
688
+ args = node.child_by_field_name("arguments")
689
+ argc = len([a for a in args.children if a.type == "argument"]) if args is not None else 0
690
+ if names:
691
+ self.res.type_refs.append(TypeRef(cid, names, "instantiate", node.start_point[0] + 1))
692
+ if names[0] in ("StreamWriter", "StreamReader"):
693
+ self.res.endpoints.append(Endpoint("file", "write" if names[0] == "StreamWriter" else "read", cid, "",
694
+ node.start_point[0] + 1, None, _cs_strings(args) if args is not None else []))
695
+ ctor_args = [a.named_children[-1] for a in args.children
696
+ if a.type == "argument" and a.named_children] if args is not None else []
697
+ if names[0] in ("RestRequest", "HttpRequestMessage"):
698
+ self._request_object(names[0], ctor_args, cid, node.start_point[0] + 1)
699
+ self.res.calls.append(CallSite(cid, ".ctor", names[0], names[0], argc,
700
+ node.start_point[0] + 1, type_id, node.start_point[1],
701
+ tuple(_arg_hint(a, scope) for a in ctor_args) if len(ctor_args) == argc else ()))
702
+ for c in node.children:
703
+ self._body(c, cid, type_id, scope)
704
+
705
+ def _use(self, node, cid: str, type_id: Optional[str], scope: dict[str, str]) -> None:
706
+ """Record a name that may be a field, and whether this use reads or assigns it."""
707
+ parent = node.parent
708
+ if parent is None:
709
+ return
710
+ pt = parent.type
711
+ same = lambda a, b: a is not None and b is not None and a.start_byte == b.start_byte and a.end_byte == b.end_byte
712
+ receiver = rtype = chain = None
713
+ if node.type == "member_access_expression":
714
+ if pt == "invocation_expression" and same(parent.child_by_field_name("function"), node):
715
+ return # a method call, recorded as a call site
716
+ name_node = node.child_by_field_name("name")
717
+ if name_node is None or name_node.type != "identifier":
718
+ return
719
+ name = _text(name_node)
720
+ expr = node.child_by_field_name("expression")
721
+ receiver, rtype = self._receiver(expr, scope, type_id)
722
+ inner = expr
723
+ while inner is not None and inner.type in ("parenthesized_expression", "await_expression") and inner.named_children:
724
+ inner = inner.named_children[-1]
725
+ if inner is not None and inner.type == "invocation_expression":
726
+ chain, receiver = self._invocation(inner, cid, type_id, scope), "?"
727
+ elif rtype is None and receiver and "~" + receiver in scope:
728
+ chain = scope["~" + receiver]
729
+ else:
730
+ name = _text(node)
731
+ if "#" + name in scope:
732
+ return # a local or a parameter
733
+ if pt == "member_access_expression" and same(parent.child_by_field_name("name"), node):
734
+ return # the member half of a.b, handled with its parent
735
+ if pt in ("invocation_expression", "generic_name", "qualified_name", "type_argument_list", "base_list",
736
+ "array_type", "nullable_type", "name_colon", "name_equals", "attribute", "variable_declarator",
737
+ "parameter", "type_parameter", "using_directive", "labeled_statement", "member_binding_expression",
738
+ "implicit_parameter", "tuple_element", "pointer_type", "type_parameter_constraint", "typeof_expression",
739
+ "alias_qualified_name", "enum_member_declaration", "local_function_statement", "catch_declaration",
740
+ "single_variable_designation", "declaration_pattern", "constant_pattern", "from_clause"):
741
+ if not (pt == "variable_declarator" and not same(parent.child_by_field_name("name") or _child(parent, "identifier"), node)):
742
+ return
743
+ if same(parent.child_by_field_name("type"), node) or same(parent.child_by_field_name("name"), node) and pt != "argument":
744
+ return # a type name, or the name being declared
745
+ if pt == "argument" and same(parent.child_by_field_name("name"), node) and any(_text(c) == ":" for c in parent.children):
746
+ return # the label of a named argument: Take(count: 5)
747
+ if pt == "anonymous_object_creation_expression" and node.next_sibling is not None and node.next_sibling.type == "=":
748
+ return # a member an anonymous object declares: new { Name = x }
749
+ if pt == "assignment_expression" and parent.parent is not None and parent.parent.type == "initializer_expression" \
750
+ and same(parent.child_by_field_name("left"), node):
751
+ made = parent.parent.parent
752
+ if made is not None and made.type == "object_creation_expression":
753
+ tn = _outer_type(made.child_by_field_name("type"))
754
+ if tn: # new Foo { a = 1 } assigns Foo.a
755
+ self.res.field_uses.append(FieldUse(cid, name, tn, tn, "i", node.start_point[0] + 1, type_id))
756
+ return
757
+ # Read or write: look at what the expression sits in.
758
+ cur, up = node, parent
759
+ # Through parentheses, and out of a tuple: (_name, _age) = (name, age) assigns both.
760
+ while up is not None and (up.type in ("parenthesized_expression", "tuple_expression") or up.type == "argument"
761
+ and up.parent is not None and up.parent.type == "tuple_expression"):
762
+ cur, up = up, up.parent
763
+ access = "r"
764
+ if up is not None:
765
+ ops = [_text(c) for c in up.children if not c.is_named]
766
+ if up.type == "assignment_expression" and same(up.child_by_field_name("left"), cur):
767
+ access = "w" if "=" in ops else "rw"
768
+ elif up.type in ("postfix_unary_expression", "prefix_unary_expression") and ("++" in ops or "--" in ops):
769
+ access = "rw"
770
+ elif up.type == "argument" and any(o in ("out", "ref") for o in ops):
771
+ access = "w" if "out" in ops else "rw"
772
+ elif up.type == "element_access_expression" and same(up.child_by_field_name("expression") or up.named_children[0], cur):
773
+ outer = up.parent
774
+ if outer is not None and outer.type == "assignment_expression" and same(outer.child_by_field_name("left"), up):
775
+ access = "rw" # field[i] = x changes what the field holds
776
+ if access == "r" and up is not None: # field.Add(x), field[k].Enqueue(x): a call that changes what the field holds
777
+ c2, u2 = cur, up
778
+ if u2.type == "element_access_expression" and same(u2.child_by_field_name("expression") or u2.named_children[0], c2):
779
+ c2, u2 = u2, u2.parent
780
+ if u2 is not None and u2.type == "member_access_expression" and same(u2.child_by_field_name("expression"), c2) \
781
+ and u2.parent is not None and u2.parent.type == "invocation_expression" \
782
+ and _text(u2.child_by_field_name("name")).split("<")[0] in MUTATORS:
783
+ access = "rw"
784
+ self.res.field_uses.append(FieldUse(cid, name, receiver, rtype, access, node.start_point[0] + 1, type_id, chain))
785
+
786
+ def _elem_of(self, expr, scope: dict[str, str], type_id: Optional[str]) -> Optional[str]:
787
+ """Element type of a collection expression that is a local, a parameter or a field."""
788
+ if expr is None:
789
+ return None
790
+ own = self.field_types.get(type_id or "", {})
791
+ if expr.type == "identifier":
792
+ return scope.get(_text(expr) + "[]") or own.get(_text(expr) + "[]")
793
+ if expr.type == "member_access_expression":
794
+ inner = expr.child_by_field_name("expression")
795
+ if inner is not None and inner.type in THIS:
796
+ return own.get(_text(expr.child_by_field_name("name")) + "[]")
797
+ return None
798
+
799
+ def _infer(self, declarator, scope: dict[str, str], type_id: Optional[str], elem: bool = False) -> Optional[str]:
800
+ """Type of `var x = <expr>` for the easy cases (or its element type when elem=True)."""
801
+ if elem:
802
+ for i, c in enumerate(declarator.children):
803
+ n = c.children[-1] if c.type == "equals_value_clause" and c.children else c
804
+ if i and n.type in ("object_creation_expression", "array_creation_expression"):
805
+ return _elem_type(n.child_by_field_name("type"))
806
+ if i and n.type == "identifier":
807
+ return self._elem_of(n, scope, type_id)
808
+ return None
809
+ for i, c in enumerate(declarator.children):
810
+ n = c
811
+ if i == 0:
812
+ continue
813
+ if n.type == "equals_value_clause":
814
+ n = n.children[-1] if n.children else n
815
+ if n.type == "object_creation_expression":
816
+ return _outer_type(n.child_by_field_name("type"))
817
+ if n.type == "cast_expression":
818
+ return _outer_type(n.child_by_field_name("type"))
819
+ if n.type == "as_expression":
820
+ return _outer_type(n.child_by_field_name("right"))
821
+ if n.type == "identifier":
822
+ return scope.get(_text(n)) or (self.field_types.get(type_id or "", {}).get(_text(n)))
823
+ if n.type == "member_access_expression":
824
+ expr = n.child_by_field_name("expression")
825
+ name = _text(n.child_by_field_name("name"))
826
+ if expr is not None and expr.type in THIS:
827
+ return self.field_types.get(type_id or "", {}).get(name)
828
+ return None
829
+
830
+ def _map_group(self, receiver: str) -> str:
831
+ """The route prefix of a minimal-API group a MapGet is called on: `app.MapGroup("api/x")` written in
832
+ place, or a variable this file sets to one (`var api = app.MapGroup("api/x")`, groups of groups too)."""
833
+ if not hasattr(self, "_groups"):
834
+ text = self.tree.root_node.text.decode("utf8", "replace")
835
+ found: dict[str, set] = {}
836
+ for m in re.finditer(r"\b(\w+)\s*=\s*([\w.]+?)\s*\.\s*MapGroup\s*\(\s*@?\"([^\"]*)\"", text):
837
+ found.setdefault(m.group(1), set()).add((m.group(2).rsplit(".", 1)[-1], m.group(3)))
838
+ self._groups = {k: next(iter(v)) for k, v in found.items() if len(v) == 1}
839
+ m = re.search(r"MapGroup\s*\(\s*@?\"([^\"]*)\"\s*\)[^\"]*$", receiver)
840
+ if m:
841
+ base = receiver[:m.start()].rstrip(". ")
842
+ return (self._map_group(base) + "/" + m.group(1).strip("/")).strip("/") if base else m.group(1).strip("/")
843
+ prefix, seen = [], set()
844
+ while receiver in self._groups and receiver not in seen:
845
+ seen.add(receiver)
846
+ receiver, part = self._groups[receiver]
847
+ prefix.insert(0, part.strip("/"))
848
+ return "/".join(p for p in prefix if p)
849
+
850
+ def _request_object(self, kind: str, ctor_args, cid: str, line: int) -> None:
851
+ """new RestRequest("api/items", Method.Post) or new HttpRequestMessage(HttpMethod.Get, "/api/items"): a
852
+ request built as an object and sent later. A path a route may serve (see Indexer._resolve_endpoints)."""
853
+ strings = [a for a in ctor_args if a.type in STRINGS]
854
+ if not strings:
855
+ return
856
+ path = (_cs_strings(strings[0]) or [""])[0]
857
+ url = path if path.startswith("/") and path.count("/") >= 2 else _relative_url(path)
858
+ if not url:
859
+ return
860
+ m = re.search(r"\b(?:Http)?Method\.(Get|Post|Put|Delete|Patch)\b", " ".join(_text(a) for a in ctor_args), re.I)
861
+ self.res.endpoints.append(Endpoint("http", "maybe", cid, url.split("?", 1)[0], line, m.group(1).upper() if m else None))
862
+
863
+ def _endpoint(self, full: str, name: str, args, arg_nodes, cid: str, line: int) -> None:
864
+ """Is this call one end of an HTTP or file channel?"""
865
+ first = arg_nodes[0] if arg_nodes else None
866
+ if first is not None and first.type in STRINGS and name in HTTP_CLIENT | HTTP_MAP:
867
+ path = (_cs_strings(first) or [""])[0]
868
+ if path.startswith(("/", "http://", "https://")) or name in HTTP_MAP:
869
+ role, method = ("serve", HTTP_MAP[name]) if name in HTTP_MAP else ("call", HTTP_CLIENT[name])
870
+ if role == "serve":
871
+ group = self._map_group(full.rsplit(".", 1)[0])
872
+ if group: # api = app.MapGroup("api/catalog"); api.MapGet("/items/{id}", ...)
873
+ path = group.rstrip("/") + "/" + path.lstrip("/") if path.strip("/") else group
874
+ self.res.endpoints.append(Endpoint("http", role, cid, path if path.startswith(("/", "http")) else "/" + path, line, method))
875
+ return
876
+ rel = _relative_url(path)
877
+ if rel: # client.GetAsync("api/items/1") against a base address, or $"{baseUrl}/api/items"
878
+ self.res.endpoints.append(Endpoint("http", "maybe", cid, rel, line, HTTP_CLIENT[name]))
879
+ return
880
+ if first is not None and first.type in STRINGS and name not in HTTP_CLIENT | HTTP_MAP \
881
+ and not FILE_CALL.search(full.replace("?", "")) and not full.endswith(("Path.Combine", "Path.Join")) \
882
+ and name not in NOT_REQUESTS:
883
+ # A request through the project's own wrapper (Get<T>("/api/items/1")): a path a route may serve.
884
+ path = (_cs_strings(first) or [""])[0]
885
+ path = path[2:] if path.startswith("{}/") else path
886
+ if not path.startswith("/"): # Get<T>("api/orders/all"), against a base address, as GetAsync takes it
887
+ path = _relative_url(path) or path
888
+ if path.startswith("/") and path.count("/") >= 2 and not any(ch.isspace() for ch in path) \
889
+ and "." not in path.rsplit("/", 1)[-1]:
890
+ self.res.endpoints.append(Endpoint("http", "maybe", cid, path.split("?", 1)[0], line, None))
891
+ return
892
+ m = FILE_CALL.search(full.replace("?", ""))
893
+ if m:
894
+ owner, op = m.group(1), m.group(2)
895
+ text = _text(args) if args is not None else ""
896
+ if owner == "FileAccess":
897
+ role = "write" if "Write" in text else "read" if op == "Open" else None
898
+ elif owner == "Directory":
899
+ role = "read" if op in ("GetFiles", "EnumerateFiles", "GetDirectories") else None
900
+ else:
901
+ role = ("write" if op.startswith(("Write", "Append", "Create", "OpenWrite", "Copy", "Move")) else
902
+ "read" if op.startswith(("Read", "OpenRead", "OpenText")) else None)
903
+ if role:
904
+ self.res.endpoints.append(Endpoint("file", role, cid, "", line, None, _cs_strings(args) if args is not None else []))
905
+ if full.endswith(("Path.Combine", "Path.Join")) and args is not None:
906
+ self.res.path_strings.setdefault(cid, []).extend(_cs_strings(args))
907
+
908
+ def _call_in(self, declarator):
909
+ """The call a variable is initialised from, looking through `await` and parentheses."""
910
+ cur = declarator.named_children[-1] if declarator.named_child_count > 1 else None
911
+ if cur is not None and cur.type == "equals_value_clause":
912
+ cur = cur.named_children[-1] if cur.named_children else None
913
+ while cur is not None and cur.type in ("await_expression", "parenthesized_expression") and cur.named_children:
914
+ cur = cur.named_children[-1]
915
+ return cur if cur is not None and cur.type == "invocation_expression" else None
916
+
917
+ def _invocation(self, node, cid: str, type_id: Optional[str], scope: dict[str, str]):
918
+ key = (node.start_byte, node.end_byte)
919
+ if key in self.done_calls:
920
+ return self.done_calls[key] # already recorded as the receiver or initialiser of something
921
+ self.done_calls[key] = None
922
+ fn = node.child_by_field_name("function")
923
+ args = node.child_by_field_name("arguments")
924
+ argc = len([a for a in args.children if a.type == "argument"]) if args is not None else 0
925
+ line = node.start_point[0] + 1
926
+ if fn is None:
927
+ return
928
+ name = None
929
+ receiver = None
930
+ rtype = None
931
+ chain = None
932
+ targs = 0
933
+ generic = fn if fn.type == "generic_name" else (
934
+ fn.child_by_field_name("name") if fn.type == "member_access_expression" else None)
935
+ if generic is not None and generic.type == "generic_name":
936
+ tl = _child(generic, "type_argument_list")
937
+ targs = len(tl.named_children) if tl is not None else 0
938
+ if fn.type in ("identifier", "generic_name"):
939
+ name = _text(_child(fn, "identifier")) if fn.type == "generic_name" else _text(fn)
940
+ elif fn.type == "member_access_expression":
941
+ nm = fn.child_by_field_name("name")
942
+ name = _text(_child(nm, "identifier")) if nm is not None and nm.type == "generic_name" else _text(nm)
943
+ expr = fn.child_by_field_name("expression")
944
+ receiver, rtype = self._receiver(expr, scope, type_id)
945
+ inner = expr
946
+ while inner is not None and inner.type in ("parenthesized_expression", "await_expression") and inner.named_children:
947
+ inner = inner.named_children[-1]
948
+ if inner is not None and inner.type == "invocation_expression":
949
+ chain = self._invocation(inner, cid, type_id, scope) # a.Make().Run(): typed by what Make returns
950
+ receiver = "?"
951
+ elif rtype is None and receiver and "~" + receiver in scope:
952
+ chain = scope["~" + receiver]
953
+ elif fn.type == "conditional_access_expression":
954
+ binding = None
955
+ for c in fn.children:
956
+ if c.type == "member_binding_expression":
957
+ binding = c
958
+ if binding is not None:
959
+ name = _text(binding.child_by_field_name("name") or _child(binding, "identifier"))
960
+ receiver, rtype = self._receiver(fn.children[0], scope, type_id)
961
+ if not name:
962
+ return
963
+ if name == "nameof":
964
+ return
965
+ if name == "Invoke" and receiver and receiver[:1].isalpha() and receiver not in ("this", "base"):
966
+ self.res.events.append(EventUse("raise", cid, receiver, None, None, None, line, type_id))
967
+ arg_nodes = [a.named_children[-1] for a in args.children if a.type == "argument" and a.named_children] if args is not None else []
968
+ if (name in TEST_RUNNERS and len(arg_nodes) == 2 and arg_nodes[0].type in STRINGS
969
+ and arg_nodes[1].type in LAMBDAS):
970
+ title = _text(arg_nodes[0]).strip('@$"')
971
+ tid = f"{cid}/test:{re.sub(r'[^A-Za-z0-9]+', '-', title).strip('-').lower()[:80]}"
972
+ lam = arg_nodes[1]
973
+ self.res.nodes.append(Node(
974
+ id=tid, kind="test", name=title, parent_id=cid, language=LANGUAGE, path=self.path,
975
+ span_start=node.start_point[0] + 1, span_end=node.end_point[0] + 1,
976
+ attrs={"framework": "inline runner", "runner": name}))
977
+ self.redirect[(lam.start_byte, lam.end_byte)] = tid
978
+ self._endpoint(_text(fn), name, args, arg_nodes, cid, line)
979
+ site = CallSite(cid, name, receiver, rtype, argc, line, type_id, node.start_point[1],
980
+ tuple(_arg_hint(a, scope) for a in arg_nodes) if len(arg_nodes) == argc else (),
981
+ targs, chain)
982
+ self.res.calls.append(site)
983
+ self.done_calls[key] = site
984
+ return site
985
+
986
+ def _receiver(self, expr, scope: dict[str, str], type_id: Optional[str]):
987
+ if expr is None:
988
+ return None, None
989
+ t = expr.type
990
+ own = self.field_types.get(type_id or "", {})
991
+ if t in THIS:
992
+ return "this", None
993
+ if t in BASE:
994
+ return "base", None
995
+ if t == "identifier":
996
+ nm = _text(expr)
997
+ return nm, scope.get(nm) or own.get(nm)
998
+ if t == "generic_name":
999
+ names = _type_names(expr) # Policy<int>.Handle(): the generic Policy, not the plain one
1000
+ return _text(_child(expr, "identifier")), (names[0] if names and "`" in names[0] else None)
1001
+ if t == "member_access_expression":
1002
+ inner = expr.child_by_field_name("expression")
1003
+ nm = _text(expr.child_by_field_name("name"))
1004
+ if inner is not None and inner.type in THIS:
1005
+ return nm, own.get(nm)
1006
+ # a.b.Call(): the receiver is member b of something; mark it as chained.
1007
+ return "." + nm, None
1008
+ if t == "object_creation_expression":
1009
+ tn = _outer_type(expr.child_by_field_name("type"))
1010
+ return tn, tn
1011
+ if t == "parenthesized_expression" and expr.named_child_count == 1:
1012
+ return self._receiver(expr.named_children[0], scope, type_id)
1013
+ if t == "cast_expression":
1014
+ tn = _outer_type(expr.child_by_field_name("type"))
1015
+ return tn, tn
1016
+ if t == "element_access_expression":
1017
+ elem = self._elem_of(expr.child_by_field_name("expression"), scope, type_id)
1018
+ return "[]", elem
1019
+ return "?", None
1020
+
1021
+
1022
+ def parse(repo: str, rel_path: str, file_id: str, src: bytes, module: str = "") -> FileResult:
1023
+ w = _Walker(repo, rel_path, file_id, src, module)
1024
+ res = w.run()
1025
+ channels.extract(LANGUAGE, w.tree, res, file_id)
1026
+ return res