codeterrain-typescript 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.
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""TypeScript and JavaScript for codeterrain: the HTTP calls of a front end, read with tree-sitter.
|
|
2
|
+
|
|
3
|
+
``terrain calls`` and ``terrain callers`` read ``.ts``, ``.tsx``, ``.js``, ``.jsx``, ``.mjs``,
|
|
4
|
+
``.cjs``, ``.mts`` and ``.cts`` files through this pack when it is installed, in place of the
|
|
5
|
+
core's tokenizer. The facts are the same: ``<receiver>.<verb>(path, ...)``, ``fetch(path,
|
|
6
|
+
{method})`` and object literals with a ``url:`` path, each with its method, its path (``${expr}``
|
|
7
|
+
as ``{}``, a leading base URL dropped) or the expression when the path is not static.
|
|
8
|
+
|
|
9
|
+
A syntax tree makes exact what a tokenizer guesses: strings, templates and comments, type
|
|
10
|
+
arguments and assertions, object fields, spreads and computed keys. Names resolve through scopes:
|
|
11
|
+
a name is read from the declaration it refers to (a ``const`` in the function around the call, a
|
|
12
|
+
parameter, a module constant declared further down), not from every declaration of that name in
|
|
13
|
+
the file. A ``let`` or ``var`` bound to a string, a name declared twice with different values,
|
|
14
|
+
and a cycle stay unresolved rather than read as a wrong path.
|
|
15
|
+
|
|
16
|
+
The pack only parses the text it is given: it reads no other file and runs nothing. A file
|
|
17
|
+
tree-sitter cannot parse whole is reported with the lines of its syntax errors, and a call inside
|
|
18
|
+
one counts as unresolved, so a partial parse never looks whole.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from functools import cache
|
|
27
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
# tree-sitter and its grammars load on the first file read, not when the pack is discovered:
|
|
31
|
+
# every terrain command discovers the packs, and most never read a front-end file.
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from tree_sitter import Language, Node, Query
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
__version__ = version("codeterrain-typescript")
|
|
37
|
+
except PackageNotFoundError: # run from a source tree
|
|
38
|
+
__version__ = "0"
|
|
39
|
+
|
|
40
|
+
VERBS = frozenset({"get", "post", "put", "patch", "delete", "head", "options"})
|
|
41
|
+
METHODS = frozenset(verb.upper() for verb in VERBS)
|
|
42
|
+
#: Receivers whose ``.get(x)`` is an HTTP call even when ``x`` cannot be read statically; the
|
|
43
|
+
#: core's tokenizer uses the same set.
|
|
44
|
+
CLIENT_NAMES = frozenset(
|
|
45
|
+
{"api", "apiClient", "axios", "axiosInstance", "client", "http", "httpClient", "request"}
|
|
46
|
+
)
|
|
47
|
+
GLOBALS = frozenset({"window", "globalThis", "self"})
|
|
48
|
+
#: A file larger than this is not read (the core's code index uses the same bound).
|
|
49
|
+
MAX_FILE_BYTES = 2_000_000
|
|
50
|
+
#: A string expression with more pieces or text than this is not a path this reader reads.
|
|
51
|
+
MAX_PIECES = 64
|
|
52
|
+
MAX_PATH_TEXT = 2048
|
|
53
|
+
RAW_LIMIT = 120
|
|
54
|
+
#: Steps one file may cost (nodes walked, scopes searched, pieces built), per byte plus a floor.
|
|
55
|
+
WORK_PER_BYTE = 32
|
|
56
|
+
WORK_FLOOR = 200_000
|
|
57
|
+
#: Lines of syntax errors named in a file's error.
|
|
58
|
+
MAX_ERROR_LINES = 5
|
|
59
|
+
|
|
60
|
+
#: The grammar each suffix is read with.
|
|
61
|
+
GRAMMAR_OF = {
|
|
62
|
+
".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "tsx",
|
|
63
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cache
|
|
68
|
+
def grammar(name: str) -> Language:
|
|
69
|
+
from tree_sitter import Language
|
|
70
|
+
|
|
71
|
+
if name == "javascript":
|
|
72
|
+
import tree_sitter_javascript
|
|
73
|
+
|
|
74
|
+
return Language(tree_sitter_javascript.language())
|
|
75
|
+
import tree_sitter_typescript
|
|
76
|
+
|
|
77
|
+
return Language(
|
|
78
|
+
tree_sitter_typescript.language_tsx() if name == "tsx"
|
|
79
|
+
else tree_sitter_typescript.language_typescript()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: Nodes that open a function scope (parameters, ``var``) and those that open a block scope.
|
|
83
|
+
_FUNCTIONS = frozenset({
|
|
84
|
+
"program", "function_declaration", "function_expression", "function", "arrow_function",
|
|
85
|
+
"method_definition", "generator_function_declaration", "generator_function",
|
|
86
|
+
})
|
|
87
|
+
_BLOCKS = frozenset({
|
|
88
|
+
"statement_block", "for_statement", "for_in_statement", "catch_clause", "switch_body",
|
|
89
|
+
"class_body",
|
|
90
|
+
})
|
|
91
|
+
#: Wrappers that say nothing about a value: ``(x)``, ``x as T``, ``x satisfies T``, ``x!``.
|
|
92
|
+
_TRANSPARENT = frozenset({
|
|
93
|
+
"parenthesized_expression", "as_expression", "satisfies_expression", "non_null_expression",
|
|
94
|
+
})
|
|
95
|
+
_STRINGS = frozenset({"string", "template_string"})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_jsx_ampersand(node: Node) -> bool:
|
|
99
|
+
"""An ``ERROR`` the grammar makes of JSX text holding a bare ``&`` (``Staff & roles``):
|
|
100
|
+
valid JSX that the grammar takes for the start of a character reference. It is text, not a
|
|
101
|
+
syntax error."""
|
|
102
|
+
parent = node.parent
|
|
103
|
+
return (
|
|
104
|
+
node.type == "ERROR" and parent is not None
|
|
105
|
+
and parent.type in ("jsx_element", "jsx_fragment")
|
|
106
|
+
and bool(node.children) and node.children[0].type == "&"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def has_syntax_error(node: Node) -> bool:
|
|
111
|
+
"""Whether a syntax error lies in ``node``, a JSX ampersand (``is_jsx_ampersand``) aside."""
|
|
112
|
+
stack = [node] if node.has_error else []
|
|
113
|
+
while stack:
|
|
114
|
+
current = stack.pop()
|
|
115
|
+
if current.is_missing or current.type == "ERROR" and not is_jsx_ampersand(current):
|
|
116
|
+
return True
|
|
117
|
+
stack.extend(c for c in current.children if c.has_error)
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _inside_error(node: Node) -> bool:
|
|
122
|
+
"""Whether ``node`` lies inside a syntax error (a JSX ampersand aside)."""
|
|
123
|
+
current = node.parent
|
|
124
|
+
while current is not None:
|
|
125
|
+
if current.type == "ERROR" and not is_jsx_ampersand(current):
|
|
126
|
+
return True
|
|
127
|
+
current = current.parent
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
#: The node kinds ``_Reader.walk`` visits: scopes, bindings and call sites.
|
|
132
|
+
_VISITED = _FUNCTIONS | _BLOCKS | frozenset({
|
|
133
|
+
"lexical_declaration", "variable_declaration", "import_statement", "class_declaration",
|
|
134
|
+
"call_expression", "object",
|
|
135
|
+
})
|
|
136
|
+
_QUERIES: dict[int, Query] = {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _query(language: Language) -> Query:
|
|
140
|
+
"""One capture per visited node kind the grammar has (the JavaScript grammar has no
|
|
141
|
+
``satisfies_expression``, for example), built once per grammar."""
|
|
142
|
+
from tree_sitter import Query
|
|
143
|
+
|
|
144
|
+
found = _QUERIES.get(id(language))
|
|
145
|
+
if found is None:
|
|
146
|
+
kinds = sorted(k for k in _VISITED if language.id_for_node_kind(k, True) is not None)
|
|
147
|
+
found = _QUERIES[id(language)] = Query(language, " ".join(f"({k}) @node" for k in kinds))
|
|
148
|
+
return found
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class WorkBoundExceeded(Exception):
|
|
152
|
+
"""A file that costs more than ``WORK_PER_BYTE`` steps per byte to read."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Binding:
|
|
157
|
+
kind: str # "const", "let", "var" or "other" (a parameter, a pattern, an import, a function)
|
|
158
|
+
value: Node | None
|
|
159
|
+
scope: Scope
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class Scope:
|
|
164
|
+
parent: Scope | None
|
|
165
|
+
function: bool
|
|
166
|
+
names: dict[str, list[Binding]] = field(default_factory=dict)
|
|
167
|
+
|
|
168
|
+
def bind(self, name: str, binding: Binding) -> None:
|
|
169
|
+
self.names.setdefault(name, []).append(binding)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
Pieces = list[tuple[str, str]]
|
|
173
|
+
_UNKNOWN = "unknown"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Reader:
|
|
177
|
+
def __init__(self, data: bytes, language: Language) -> None:
|
|
178
|
+
self.data = data
|
|
179
|
+
self.language = language
|
|
180
|
+
self.work = WORK_FLOOR + WORK_PER_BYTE * len(data)
|
|
181
|
+
self.done: dict[int, Pieces | None] = {}
|
|
182
|
+
self.busy: set[int] = set()
|
|
183
|
+
|
|
184
|
+
def spend(self, steps: int = 1) -> None:
|
|
185
|
+
self.work -= steps
|
|
186
|
+
if self.work < 0:
|
|
187
|
+
raise WorkBoundExceeded("too costly to read within the work bound; the file is skipped")
|
|
188
|
+
|
|
189
|
+
def text(self, node: Node) -> str:
|
|
190
|
+
return self.data[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
191
|
+
|
|
192
|
+
# --- scopes ------------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def walk(self, root: Node) -> list[tuple[Node, Scope, bool]]:
|
|
195
|
+
"""Every call and object literal with the scope it sits in and whether it lies inside a
|
|
196
|
+
syntax error; every declaration is bound into its scope on the way."""
|
|
197
|
+
sites: list[tuple[Node, Scope, bool]] = []
|
|
198
|
+
top = Scope(None, True)
|
|
199
|
+
# Only the nodes that open a scope, bind a name or may be a call are visited: the query
|
|
200
|
+
# finds them in C, and one pass in source order (outer before inner) gives each the
|
|
201
|
+
# innermost scope still open where it starts.
|
|
202
|
+
from tree_sitter import QueryCursor
|
|
203
|
+
|
|
204
|
+
nodes = QueryCursor(_query(self.language)).captures(root).get("node", [])
|
|
205
|
+
nodes.sort(key=lambda n: (n.start_byte, -n.end_byte))
|
|
206
|
+
open_scopes: list[tuple[int, Scope]] = [(len(self.data) + 1, top)]
|
|
207
|
+
damaged = root.has_error
|
|
208
|
+
for node in nodes:
|
|
209
|
+
self.spend()
|
|
210
|
+
while open_scopes[-1][0] <= node.start_byte:
|
|
211
|
+
open_scopes.pop()
|
|
212
|
+
scope = open_scopes[-1][1]
|
|
213
|
+
kind = node.type
|
|
214
|
+
if kind == "program":
|
|
215
|
+
continue
|
|
216
|
+
if kind in ("function_declaration", "generator_function_declaration",
|
|
217
|
+
"class_declaration"):
|
|
218
|
+
name = node.child_by_field_name("name")
|
|
219
|
+
if name is not None:
|
|
220
|
+
scope.bind(self.text(name), Binding("other", None, scope))
|
|
221
|
+
if kind in _FUNCTIONS:
|
|
222
|
+
scope = Scope(scope, True)
|
|
223
|
+
self._bind_parameters(node, scope)
|
|
224
|
+
open_scopes.append((node.end_byte, scope))
|
|
225
|
+
elif kind in _BLOCKS:
|
|
226
|
+
scope = Scope(scope, False)
|
|
227
|
+
open_scopes.append((node.end_byte, scope))
|
|
228
|
+
if kind in ("lexical_declaration", "variable_declaration"):
|
|
229
|
+
self._bind_declaration(node, scope)
|
|
230
|
+
elif kind == "for_in_statement":
|
|
231
|
+
left = node.child_by_field_name("left")
|
|
232
|
+
keyword = node.child_by_field_name("kind")
|
|
233
|
+
if left is not None and keyword is not None:
|
|
234
|
+
# `for (var x of xs)` binds x in the function, `let` and `const` in the loop
|
|
235
|
+
target = scope
|
|
236
|
+
if keyword.type == "var":
|
|
237
|
+
while not target.function and target.parent is not None:
|
|
238
|
+
target = target.parent
|
|
239
|
+
for name in self._pattern_names(left):
|
|
240
|
+
target.bind(name, Binding("other", None, scope))
|
|
241
|
+
elif kind == "catch_clause":
|
|
242
|
+
parameter = node.child_by_field_name("parameter")
|
|
243
|
+
if parameter is not None:
|
|
244
|
+
for name in self._pattern_names(parameter):
|
|
245
|
+
scope.bind(name, Binding("other", None, scope))
|
|
246
|
+
elif kind == "import_statement":
|
|
247
|
+
for name in self._import_names(node):
|
|
248
|
+
top.bind(name, Binding("other", None, top))
|
|
249
|
+
elif kind in ("call_expression", "object"):
|
|
250
|
+
sites.append((node, scope, damaged and _inside_error(node)))
|
|
251
|
+
return sites
|
|
252
|
+
|
|
253
|
+
def _bind_parameters(self, node: Node, scope: Scope) -> None:
|
|
254
|
+
name = node.child_by_field_name("name")
|
|
255
|
+
if name is not None and node.type in ("function_expression", "function"):
|
|
256
|
+
scope.bind(self.text(name), Binding("other", None, scope))
|
|
257
|
+
single = node.child_by_field_name("parameter") # `url => ...`
|
|
258
|
+
params = node.child_by_field_name("parameters")
|
|
259
|
+
for holder in (single, params):
|
|
260
|
+
if holder is not None:
|
|
261
|
+
for found in self._pattern_names(holder):
|
|
262
|
+
scope.bind(found, Binding("other", None, scope))
|
|
263
|
+
|
|
264
|
+
def _bind_declaration(self, node: Node, scope: Scope) -> None:
|
|
265
|
+
keyword = node.children[0].type if node.children else ""
|
|
266
|
+
kind = keyword if keyword in ("const", "let") else "var"
|
|
267
|
+
target = scope
|
|
268
|
+
if kind == "var":
|
|
269
|
+
while not target.function and target.parent is not None:
|
|
270
|
+
target = target.parent
|
|
271
|
+
for declarator in node.named_children:
|
|
272
|
+
if declarator.type != "variable_declarator":
|
|
273
|
+
continue
|
|
274
|
+
name = declarator.child_by_field_name("name")
|
|
275
|
+
value = declarator.child_by_field_name("value")
|
|
276
|
+
if name is None:
|
|
277
|
+
continue
|
|
278
|
+
if name.type == "identifier":
|
|
279
|
+
target.bind(self.text(name), Binding(kind, value, scope))
|
|
280
|
+
else:
|
|
281
|
+
for found in self._pattern_names(name):
|
|
282
|
+
target.bind(found, Binding("other", None, scope))
|
|
283
|
+
|
|
284
|
+
def _pattern_names(self, node: Node) -> Iterator[str]:
|
|
285
|
+
"""The names a parameter list or a binding pattern binds; default values and types are
|
|
286
|
+
not names it binds."""
|
|
287
|
+
stack = [node]
|
|
288
|
+
while stack:
|
|
289
|
+
current = stack.pop()
|
|
290
|
+
self.spend()
|
|
291
|
+
kind = current.type
|
|
292
|
+
if kind in ("identifier", "shorthand_property_identifier_pattern"):
|
|
293
|
+
yield self.text(current)
|
|
294
|
+
elif kind in ("assignment_pattern", "object_assignment_pattern"):
|
|
295
|
+
left = current.child_by_field_name("left")
|
|
296
|
+
if left is not None:
|
|
297
|
+
stack.append(left)
|
|
298
|
+
elif kind == "pair_pattern":
|
|
299
|
+
value = current.child_by_field_name("value")
|
|
300
|
+
if value is not None:
|
|
301
|
+
stack.append(value)
|
|
302
|
+
elif kind in ("required_parameter", "optional_parameter"):
|
|
303
|
+
pattern = current.child_by_field_name("pattern")
|
|
304
|
+
if pattern is not None:
|
|
305
|
+
stack.append(pattern)
|
|
306
|
+
elif kind in ("formal_parameters", "object_pattern", "array_pattern", "rest_pattern"):
|
|
307
|
+
stack.extend(current.named_children)
|
|
308
|
+
|
|
309
|
+
def _import_names(self, node: Node) -> Iterator[str]:
|
|
310
|
+
stack = [c for c in node.named_children if c.type == "import_clause"]
|
|
311
|
+
while stack:
|
|
312
|
+
current = stack.pop()
|
|
313
|
+
self.spend()
|
|
314
|
+
if current.type == "identifier":
|
|
315
|
+
yield self.text(current)
|
|
316
|
+
elif current.type == "import_specifier":
|
|
317
|
+
alias = current.child_by_field_name("alias") or current.child_by_field_name("name")
|
|
318
|
+
if alias is not None:
|
|
319
|
+
yield self.text(alias)
|
|
320
|
+
else:
|
|
321
|
+
stack.extend(current.named_children)
|
|
322
|
+
|
|
323
|
+
def lookup(self, name: str, scope: Scope) -> list[Binding] | None:
|
|
324
|
+
current: Scope | None = scope
|
|
325
|
+
while current is not None:
|
|
326
|
+
self.spend()
|
|
327
|
+
if name in current.names:
|
|
328
|
+
return current.names[name]
|
|
329
|
+
current = current.parent
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
# --- values ------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def name_pieces(self, name: str, scope: Scope) -> Pieces | None:
|
|
335
|
+
"""What the name ``name`` holds where ``scope`` reads it: its pieces when it is a
|
|
336
|
+
constant this reader can read; ``unknown`` when it is bound to a string this reader
|
|
337
|
+
cannot pin down; None when it is not a string constant (a parameter, an import, a base
|
|
338
|
+
URL from the environment)."""
|
|
339
|
+
bindings = self.lookup(name, scope)
|
|
340
|
+
if not bindings:
|
|
341
|
+
return None
|
|
342
|
+
key = id(bindings)
|
|
343
|
+
if key in self.done:
|
|
344
|
+
return self.done[key]
|
|
345
|
+
if key in self.busy:
|
|
346
|
+
return [(_UNKNOWN, name)]
|
|
347
|
+
constants = [b for b in bindings if b.kind == "const" and b.value is not None]
|
|
348
|
+
others = [b for b in bindings if not (b.kind == "const" and b.value is not None)]
|
|
349
|
+
result: Pieces | None
|
|
350
|
+
if constants and others:
|
|
351
|
+
result = [(_UNKNOWN, name)]
|
|
352
|
+
elif others:
|
|
353
|
+
mutable = [b for b in others if b.kind in ("let", "var") and b.value is not None]
|
|
354
|
+
result = [(_UNKNOWN, name)] if any(self.stringish(b.value) for b in mutable) else None
|
|
355
|
+
else:
|
|
356
|
+
self.busy.add(key)
|
|
357
|
+
found = [self.pieces(b.value, b.scope) for b in constants]
|
|
358
|
+
self.busy.discard(key)
|
|
359
|
+
readable = [p for p in found if p is not None and any(k != "expr" for k, _ in p)]
|
|
360
|
+
if readable and len(readable) == len(found) and all(p == readable[0] for p in found):
|
|
361
|
+
result = readable[0]
|
|
362
|
+
elif readable or any(self.stringish(b.value) for b in constants):
|
|
363
|
+
result = [(_UNKNOWN, name)]
|
|
364
|
+
else:
|
|
365
|
+
result = None
|
|
366
|
+
self.done[key] = result
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
def stringish(self, node: Node | None) -> bool:
|
|
370
|
+
"""Whether a string or template literal appears anywhere in ``node``."""
|
|
371
|
+
stack = [node] if node is not None else []
|
|
372
|
+
while stack:
|
|
373
|
+
current = stack.pop()
|
|
374
|
+
self.spend()
|
|
375
|
+
if current.type in _STRINGS:
|
|
376
|
+
return True
|
|
377
|
+
stack.extend(current.named_children)
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def unwrap(self, node: Node) -> Node:
|
|
381
|
+
while node.type in _TRANSPARENT and node.named_children:
|
|
382
|
+
self.spend()
|
|
383
|
+
node = node.named_children[0]
|
|
384
|
+
if node.type == "type_assertion" and node.named_children: # `<T>value`
|
|
385
|
+
return self.unwrap(node.named_children[-1])
|
|
386
|
+
return node
|
|
387
|
+
|
|
388
|
+
def pieces(self, node: Node, scope: Scope) -> Pieces | None:
|
|
389
|
+
"""``node`` as ("text", s) / ("expr", source) / ("unknown", name) pieces, or None when
|
|
390
|
+
it is not a string expression this reader reads: a string, a template, a name, or a
|
|
391
|
+
``+`` of those."""
|
|
392
|
+
out: Pieces = []
|
|
393
|
+
size = 0
|
|
394
|
+
for more in self._operands(node, scope):
|
|
395
|
+
if more is None:
|
|
396
|
+
return None
|
|
397
|
+
size += sum(len(value) for _, value in more)
|
|
398
|
+
if len(out) + len(more) > MAX_PIECES or size > MAX_PATH_TEXT:
|
|
399
|
+
return None
|
|
400
|
+
out.extend(more)
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
def _operands(self, node: Node, scope: Scope) -> Iterator[Pieces | None]:
|
|
404
|
+
stack = [self.unwrap(node)]
|
|
405
|
+
while stack:
|
|
406
|
+
current = stack.pop()
|
|
407
|
+
self.spend()
|
|
408
|
+
kind = current.type
|
|
409
|
+
if kind == "binary_expression":
|
|
410
|
+
operator = current.child_by_field_name("operator")
|
|
411
|
+
left = current.child_by_field_name("left")
|
|
412
|
+
right = current.child_by_field_name("right")
|
|
413
|
+
if operator is None or operator.type != "+" or left is None or right is None:
|
|
414
|
+
yield None
|
|
415
|
+
return
|
|
416
|
+
stack.extend((self.unwrap(right), self.unwrap(left)))
|
|
417
|
+
elif kind == "string":
|
|
418
|
+
yield [("text", decode(self.text(current)[1:-1]))]
|
|
419
|
+
elif kind == "template_string":
|
|
420
|
+
yield self._template(current, scope)
|
|
421
|
+
elif kind == "identifier":
|
|
422
|
+
name = self.text(current)
|
|
423
|
+
yield self.name_pieces(name, scope) or [("expr", name)]
|
|
424
|
+
elif kind in ("member_expression", "number"):
|
|
425
|
+
yield [("expr", self.text(current))]
|
|
426
|
+
else:
|
|
427
|
+
yield None
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
def _template(self, node: Node, scope: Scope) -> Pieces:
|
|
431
|
+
out: Pieces = []
|
|
432
|
+
at = node.start_byte + 1
|
|
433
|
+
for child in node.named_children:
|
|
434
|
+
if child.type != "template_substitution":
|
|
435
|
+
continue
|
|
436
|
+
if child.start_byte > at:
|
|
437
|
+
out.append(("text", decode(self.data[at:child.start_byte].decode(
|
|
438
|
+
"utf-8", errors="replace"))))
|
|
439
|
+
inner = [c for c in child.named_children if c.type != "comment"]
|
|
440
|
+
expr = self.unwrap(inner[0]) if len(inner) == 1 else None
|
|
441
|
+
found = None
|
|
442
|
+
if expr is not None and expr.type == "identifier":
|
|
443
|
+
found = self.name_pieces(self.text(expr), scope)
|
|
444
|
+
out.extend(found or [("expr", " ".join(self.text(child)[2:-1].split()))])
|
|
445
|
+
at = child.end_byte
|
|
446
|
+
end = node.end_byte - 1
|
|
447
|
+
if end > at:
|
|
448
|
+
out.append(("text", decode(self.data[at:end].decode("utf-8", errors="replace"))))
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
def path(self, node: Node, scope: Scope) -> str | None:
|
|
452
|
+
pieces = self.pieces(node, scope)
|
|
453
|
+
return None if pieces is None else path_from_pieces(pieces)
|
|
454
|
+
|
|
455
|
+
def external(self, node: Node) -> bool:
|
|
456
|
+
"""``fetch('https://...')``: an absolute URL literal is not a call to the backend."""
|
|
457
|
+
node = self.unwrap(node)
|
|
458
|
+
if node.type == "string":
|
|
459
|
+
return "://" in decode(self.text(node)[1:-1])
|
|
460
|
+
if node.type == "template_string":
|
|
461
|
+
first = next((c for c in node.named_children), None)
|
|
462
|
+
stop = first.start_byte if first is not None and \
|
|
463
|
+
first.type == "template_substitution" else node.end_byte - 1
|
|
464
|
+
return "://" in self.data[node.start_byte + 1:stop].decode("utf-8", errors="replace")
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
def raw(self, node: Node) -> str:
|
|
468
|
+
return " ".join(self.text(node).split())[:RAW_LIMIT]
|
|
469
|
+
|
|
470
|
+
# --- call sites --------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def fields(self, node: Node, scope: Scope) -> tuple[dict[str, Node], bool]:
|
|
473
|
+
"""Top-level fields of an object literal, and whether an entry could set any field: a
|
|
474
|
+
``...spread`` or a computed key that is not one string. Such an entry drops the fields
|
|
475
|
+
before it; those after it are kept."""
|
|
476
|
+
found: dict[str, Node] = {}
|
|
477
|
+
overwrites = False
|
|
478
|
+
for entry in node.named_children:
|
|
479
|
+
self.spend()
|
|
480
|
+
if entry.type == "spread_element":
|
|
481
|
+
found.clear()
|
|
482
|
+
overwrites = True
|
|
483
|
+
elif entry.type == "shorthand_property_identifier":
|
|
484
|
+
found[self.text(entry)] = entry
|
|
485
|
+
elif entry.type == "pair":
|
|
486
|
+
key = entry.child_by_field_name("key")
|
|
487
|
+
value = entry.child_by_field_name("value")
|
|
488
|
+
if key is None or value is None:
|
|
489
|
+
continue
|
|
490
|
+
name = self.key(key)
|
|
491
|
+
if name is None:
|
|
492
|
+
found.clear()
|
|
493
|
+
overwrites = True
|
|
494
|
+
else:
|
|
495
|
+
found[name] = value
|
|
496
|
+
return found, overwrites
|
|
497
|
+
|
|
498
|
+
def key(self, key: Node) -> str | None:
|
|
499
|
+
if key.type in ("property_identifier", "number"):
|
|
500
|
+
return self.text(key)
|
|
501
|
+
if key.type == "string":
|
|
502
|
+
return decode(self.text(key)[1:-1])
|
|
503
|
+
if key.type == "computed_property_name":
|
|
504
|
+
inner = [self.unwrap(c) for c in key.named_children]
|
|
505
|
+
if len(inner) == 1 and inner[0].type == "string":
|
|
506
|
+
return decode(self.text(inner[0])[1:-1])
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
def method(self, value: Node | None, scope: Scope, default: str | None) -> str:
|
|
510
|
+
if value is None:
|
|
511
|
+
return default or "?"
|
|
512
|
+
if value.type == "shorthand_property_identifier":
|
|
513
|
+
pieces = self.name_pieces(self.text(value), scope)
|
|
514
|
+
else:
|
|
515
|
+
pieces = self.pieces(value, scope)
|
|
516
|
+
if pieces and all(kind == "text" for kind, _ in pieces):
|
|
517
|
+
method = "".join(text for _, text in pieces).upper()
|
|
518
|
+
return method if method in METHODS else "?"
|
|
519
|
+
return "?"
|
|
520
|
+
|
|
521
|
+
def calls(self, sites: list[tuple[Node, Scope, bool]]) -> list[dict[str, Any]]:
|
|
522
|
+
out: list[dict[str, Any]] = []
|
|
523
|
+
for node, scope, broken in sites:
|
|
524
|
+
call = self._client(node, scope) if node.type == "call_expression" else None
|
|
525
|
+
if call is None and node.type == "call_expression":
|
|
526
|
+
call = self._fetch(node, scope)
|
|
527
|
+
if call is None and node.type == "object":
|
|
528
|
+
call = self._object(node, scope)
|
|
529
|
+
if call is None:
|
|
530
|
+
continue
|
|
531
|
+
method, path, line, style, arg = call
|
|
532
|
+
if broken or has_syntax_error(node): # a syntax error in the call or around it
|
|
533
|
+
path = None
|
|
534
|
+
out.append({
|
|
535
|
+
"method": method, "path": path or "", "line": line, "style": style,
|
|
536
|
+
"resolved": path is not None, "raw": "" if path is not None else self.raw(arg),
|
|
537
|
+
})
|
|
538
|
+
out.sort(key=lambda c: c["line"])
|
|
539
|
+
return out
|
|
540
|
+
|
|
541
|
+
def _callee(self, node: Node) -> Node | None:
|
|
542
|
+
"""What ``node`` calls. The TypeScript grammar reads ``await api.get<T>(path)`` as a
|
|
543
|
+
call of ``await api.get`` (with type arguments), so the ``await`` is looked through."""
|
|
544
|
+
function = node.child_by_field_name("function")
|
|
545
|
+
if function is not None and function.type == "await_expression" and \
|
|
546
|
+
node.child_by_field_name("type_arguments") is not None:
|
|
547
|
+
inner = [c for c in function.named_children if c.type != "comment"]
|
|
548
|
+
return inner[0] if len(inner) == 1 else None
|
|
549
|
+
return function
|
|
550
|
+
|
|
551
|
+
def _arguments(self, node: Node) -> list[Node] | None:
|
|
552
|
+
args = node.child_by_field_name("arguments")
|
|
553
|
+
if args is None or args.type != "arguments":
|
|
554
|
+
return None
|
|
555
|
+
found = [c for c in args.named_children if c.type != "comment"]
|
|
556
|
+
return found or None
|
|
557
|
+
|
|
558
|
+
def _client(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
|
|
559
|
+
"""``<receiver>.<verb>[<T>](path, ...)``."""
|
|
560
|
+
function = self._callee(node)
|
|
561
|
+
if function is None or function.type != "member_expression":
|
|
562
|
+
return None
|
|
563
|
+
prop = function.child_by_field_name("property")
|
|
564
|
+
receiver = self._receiver(function.child_by_field_name("object"))
|
|
565
|
+
if prop is None or prop.type != "property_identifier" or receiver is None:
|
|
566
|
+
return None
|
|
567
|
+
verb = self.text(prop)
|
|
568
|
+
args = self._arguments(node)
|
|
569
|
+
if verb not in VERBS or args is None:
|
|
570
|
+
return None
|
|
571
|
+
path = self.path(args[0], scope)
|
|
572
|
+
if path is None and (self.external(args[0]) or receiver not in CLIENT_NAMES):
|
|
573
|
+
return None
|
|
574
|
+
return verb.upper(), path, prop.start_point[0] + 1, "client", args[0]
|
|
575
|
+
|
|
576
|
+
def _receiver(self, node: Node | None) -> str | None:
|
|
577
|
+
"""The last name of a plain receiver chain (``this.api`` is ``api``), or None."""
|
|
578
|
+
if node is None:
|
|
579
|
+
return None
|
|
580
|
+
if node.type in ("identifier", "this"):
|
|
581
|
+
return self.text(node)
|
|
582
|
+
last = None
|
|
583
|
+
current = node
|
|
584
|
+
while current.type == "member_expression":
|
|
585
|
+
self.spend()
|
|
586
|
+
prop = current.child_by_field_name("property")
|
|
587
|
+
if prop is None or prop.type != "property_identifier":
|
|
588
|
+
return None
|
|
589
|
+
last = last or self.text(prop)
|
|
590
|
+
current = current.child_by_field_name("object")
|
|
591
|
+
if current is None:
|
|
592
|
+
return None
|
|
593
|
+
return last if current.type in ("identifier", "this") else None
|
|
594
|
+
|
|
595
|
+
def _fetch(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
|
|
596
|
+
"""``fetch(path, {method})`` and ``window.fetch(...)``; ``client.fetch`` is someone
|
|
597
|
+
else's method."""
|
|
598
|
+
function = self._callee(node)
|
|
599
|
+
if function is None:
|
|
600
|
+
return None
|
|
601
|
+
if function.type == "identifier" and self.text(function) == "fetch":
|
|
602
|
+
name = function
|
|
603
|
+
elif function.type == "member_expression":
|
|
604
|
+
name = function.child_by_field_name("property")
|
|
605
|
+
owner = function.child_by_field_name("object")
|
|
606
|
+
if name is None or self.text(name) != "fetch" or owner is None or \
|
|
607
|
+
owner.type != "identifier" or self.text(owner) not in GLOBALS:
|
|
608
|
+
return None
|
|
609
|
+
else:
|
|
610
|
+
return None
|
|
611
|
+
args = self._arguments(node)
|
|
612
|
+
if args is None:
|
|
613
|
+
return None
|
|
614
|
+
path = self.path(args[0], scope)
|
|
615
|
+
if path is None and self.external(args[0]):
|
|
616
|
+
return None
|
|
617
|
+
options = self.unwrap(args[1]) if len(args) > 1 else None
|
|
618
|
+
fields: dict[str, Node] = {}
|
|
619
|
+
default: str | None
|
|
620
|
+
if options is None:
|
|
621
|
+
default = "GET"
|
|
622
|
+
elif options.type == "object":
|
|
623
|
+
fields, overwrites = self.fields(options, scope)
|
|
624
|
+
default = None if overwrites else "GET"
|
|
625
|
+
else:
|
|
626
|
+
default = None
|
|
627
|
+
method = self.method(fields.get("method"), scope, default)
|
|
628
|
+
return method, path, name.start_point[0] + 1, "fetch", args[0]
|
|
629
|
+
|
|
630
|
+
def _object(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
|
|
631
|
+
"""``{ url: path, method: 'post' }`` and the shorthand ``{ url, method: 'post' }``."""
|
|
632
|
+
fields, overwrites = self.fields(node, scope)
|
|
633
|
+
value = fields.get("url")
|
|
634
|
+
if value is None:
|
|
635
|
+
return None
|
|
636
|
+
if value.type == "shorthand_property_identifier":
|
|
637
|
+
pieces = self.name_pieces(self.text(value), scope)
|
|
638
|
+
path = None if pieces is None else path_from_pieces(pieces)
|
|
639
|
+
line = value.start_point[0] + 1
|
|
640
|
+
else:
|
|
641
|
+
path = self.path(value, scope)
|
|
642
|
+
line = (value.parent or value).start_point[0] + 1
|
|
643
|
+
if path is None:
|
|
644
|
+
return None # an object with a `url` that is not a path is not evidence of a call
|
|
645
|
+
method = self.method(fields.get("method"), scope, None if overwrites else "GET")
|
|
646
|
+
return method, path, line, "object", value
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def path_from_pieces(pieces: Pieces) -> str | None:
|
|
650
|
+
"""The path the pieces spell, ``${expr}`` as ``{}``; a leading expression followed by ``/``
|
|
651
|
+
is a base URL and dropped. None unless the result starts with ``/``, and None when a piece is
|
|
652
|
+
a name bound to a string this reader cannot read."""
|
|
653
|
+
if any(kind == _UNKNOWN for kind, _ in pieces):
|
|
654
|
+
return None
|
|
655
|
+
if len(pieces) > 1 and pieces[0][0] == "expr" and pieces[1][0] == "text" and \
|
|
656
|
+
pieces[1][1].startswith("/"):
|
|
657
|
+
pieces = pieces[1:]
|
|
658
|
+
path = "".join(value if kind == "text" else "{}" for kind, value in pieces)
|
|
659
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
660
|
+
return None
|
|
661
|
+
return path
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
_SIMPLE_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
|
|
665
|
+
_ESCAPE = re.compile(
|
|
666
|
+
r"\\(?:x([0-9A-Fa-f]{2})|u([0-9A-Fa-f]{4})|u\{([0-9A-Fa-f]{1,6})\}|(\r\n|[\s\S]))"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def decode(text: str) -> str:
|
|
671
|
+
"""The value of a string's or template text's source: ``\\n`` and the like, ``\\xHH``,
|
|
672
|
+
``\\uHHHH``, ``\\u{H...}``, a line continuation (nothing), any other character as itself. A
|
|
673
|
+
malformed hex escape keeps its letters, so the path it spells cannot match a route by
|
|
674
|
+
accident."""
|
|
675
|
+
def one(match: re.Match[str]) -> str:
|
|
676
|
+
hexed = next((g for g in match.groups()[:3] if g is not None), None)
|
|
677
|
+
if hexed is not None:
|
|
678
|
+
code = int(hexed, 16)
|
|
679
|
+
return chr(code) if code <= 0x10FFFF else match.group(0)[1:]
|
|
680
|
+
char = match.group(4)
|
|
681
|
+
if char in ("\n", "\r\n", "\r", "\u2028", "\u2029"):
|
|
682
|
+
return ""
|
|
683
|
+
return _SIMPLE_ESCAPES.get(char, char)
|
|
684
|
+
|
|
685
|
+
return _ESCAPE.sub(one, text) if "\\" in text else text
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _syntax_errors(root: Node, reader: _Reader) -> list[int]:
|
|
689
|
+
lines: list[int] = []
|
|
690
|
+
stack = [root]
|
|
691
|
+
while stack and len(lines) < MAX_ERROR_LINES:
|
|
692
|
+
node = stack.pop()
|
|
693
|
+
reader.spend()
|
|
694
|
+
if is_jsx_ampersand(node):
|
|
695
|
+
continue
|
|
696
|
+
if node.type == "ERROR" or node.is_missing:
|
|
697
|
+
line = node.start_point[0] + 1
|
|
698
|
+
if line not in lines:
|
|
699
|
+
lines.append(line)
|
|
700
|
+
continue
|
|
701
|
+
if node.has_error:
|
|
702
|
+
stack.extend(reversed(node.children))
|
|
703
|
+
return sorted(lines)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def read_calls(source: str, suffix: str) -> dict[str, Any]:
|
|
707
|
+
"""The HTTP calls of one file's text: ``{"calls": [...], "error": str | None}``."""
|
|
708
|
+
data = source.encode("utf-8", errors="replace")
|
|
709
|
+
if len(data) > MAX_FILE_BYTES:
|
|
710
|
+
return {"calls": [], "error": f"file larger than {MAX_FILE_BYTES} bytes; not read"}
|
|
711
|
+
from tree_sitter import Parser
|
|
712
|
+
|
|
713
|
+
language = grammar(GRAMMAR_OF[suffix])
|
|
714
|
+
tree = Parser(language).parse(data)
|
|
715
|
+
reader = _Reader(data, language)
|
|
716
|
+
try:
|
|
717
|
+
calls = reader.calls(reader.walk(tree.root_node))
|
|
718
|
+
error = None
|
|
719
|
+
lines = ", ".join(str(n) for n in _syntax_errors(tree.root_node, reader))
|
|
720
|
+
if lines:
|
|
721
|
+
error = f"syntax errors at line {lines}; calls there are unresolved"
|
|
722
|
+
except (WorkBoundExceeded, RecursionError) as exc:
|
|
723
|
+
return {"calls": [], "error": f"{type(exc).__name__}: {exc}"}
|
|
724
|
+
return {"calls": calls, "error": error}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class _Language:
|
|
728
|
+
interface = 1
|
|
729
|
+
call_suffixes = tuple(GRAMMAR_OF)
|
|
730
|
+
|
|
731
|
+
@staticmethod
|
|
732
|
+
def read_calls(source: str, suffix: str) -> dict[str, Any]:
|
|
733
|
+
return read_calls(source, suffix)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
language = _Language()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codeterrain-typescript
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TypeScript and JavaScript for codeterrain: front-end HTTP calls read with tree-sitter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/benchli-sports/codeterrain
|
|
6
|
+
Project-URL: Issues, https://github.com/benchli-sports/codeterrain/issues
|
|
7
|
+
Author: Mitbolas GmbH
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: codeterrain>=0.3
|
|
11
|
+
Requires-Dist: tree-sitter-javascript==0.25.0
|
|
12
|
+
Requires-Dist: tree-sitter-typescript==0.23.2
|
|
13
|
+
Requires-Dist: tree-sitter==0.26.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# codeterrain-typescript
|
|
17
|
+
|
|
18
|
+
TypeScript and JavaScript for [codeterrain](https://github.com/benchli-sports/codeterrain): the HTTP
|
|
19
|
+
calls of a front end, read with [tree-sitter](https://tree-sitter.github.io/), in `terrain calls` and
|
|
20
|
+
`terrain callers`.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uv tool install "<path-to-the-codeterrain-checkout>[typescript]"
|
|
24
|
+
terrain calls orders # GET /v1/orders src/api/orders.ts:12 client
|
|
25
|
+
terrain callers POST /v1/orders # the front-end calls that reach a route
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
|
|
29
|
+
`packages/`. Once published, `pip install "codeterrain[typescript]"` will do the same.
|
|
30
|
+
`terrain doctor` lists the installed packs (`language packs: typescript 0.1.0`). Without the pack,
|
|
31
|
+
`terrain calls` reads the same files with the core's own tokenizer.
|
|
32
|
+
|
|
33
|
+
Why install it: it is both more accurate and faster than the tokenizer. It parses the full syntax
|
|
34
|
+
and resolves names through scopes, and a cold read takes about 0.6 to 0.7 times the tokenizer's time,
|
|
35
|
+
since a tree-sitter query hands the reader only the nodes it needs. tree-sitter loads on the first
|
|
36
|
+
front-end file read, so commands that read none barely notice the pack.
|
|
37
|
+
|
|
38
|
+
The facts are the core's: `<receiver>.<verb>(path, ...)`, `fetch(path, {method})` and object literals
|
|
39
|
+
with a `url:` path. The syntax tree makes strings, templates, comments, type arguments and assertions,
|
|
40
|
+
spreads and computed keys exact, and names resolve through scopes: a name is read from the declaration
|
|
41
|
+
it refers to, so a `const url` inside a function is that function's, and a parameter hides a module
|
|
42
|
+
constant of the same name only inside its function. A file tree-sitter cannot parse whole is reported
|
|
43
|
+
with the lines of its syntax errors, and a call inside one counts as unresolved.
|
|
44
|
+
|
|
45
|
+
It depends on `tree-sitter` and the TypeScript and JavaScript grammars, which ship prebuilt wheels
|
|
46
|
+
for Linux, macOS and Windows; the versions are pinned exactly, since grammars rename nodes between
|
|
47
|
+
releases. The pack only parses the text it is given: it reads no other file and runs nothing.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
codeterrain_typescript/__init__.py,sha256=LWobqxCBEHT3I1UwgbWgshAvPr6v8r2Sz4pBDzif3Lo,32312
|
|
2
|
+
codeterrain_typescript-0.1.0.dist-info/METADATA,sha256=CQKZ7yedda2mpdQF8J8gf6z-ESOOj8EomznjO7VSPqU,2667
|
|
3
|
+
codeterrain_typescript-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
4
|
+
codeterrain_typescript-0.1.0.dist-info/entry_points.txt,sha256=DfGvtQIdB9-INJ_YerKx7w5c619_16uwttb8IFN1nF0,69
|
|
5
|
+
codeterrain_typescript-0.1.0.dist-info/RECORD,,
|