codeterrain-typescript 0.1.0__tar.gz
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.
- codeterrain_typescript-0.1.0/.gitignore +12 -0
- codeterrain_typescript-0.1.0/PKG-INFO +47 -0
- codeterrain_typescript-0.1.0/README.md +32 -0
- codeterrain_typescript-0.1.0/pyproject.toml +33 -0
- codeterrain_typescript-0.1.0/src/codeterrain_typescript/__init__.py +736 -0
- codeterrain_typescript-0.1.0/tests/test_typescript.py +298 -0
|
@@ -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,32 @@
|
|
|
1
|
+
# codeterrain-typescript
|
|
2
|
+
|
|
3
|
+
TypeScript and JavaScript for [codeterrain](https://github.com/benchli-sports/codeterrain): the HTTP
|
|
4
|
+
calls of a front end, read with [tree-sitter](https://tree-sitter.github.io/), in `terrain calls` and
|
|
5
|
+
`terrain callers`.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
uv tool install "<path-to-the-codeterrain-checkout>[typescript]"
|
|
9
|
+
terrain calls orders # GET /v1/orders src/api/orders.ts:12 client
|
|
10
|
+
terrain callers POST /v1/orders # the front-end calls that reach a route
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
|
|
14
|
+
`packages/`. Once published, `pip install "codeterrain[typescript]"` will do the same.
|
|
15
|
+
`terrain doctor` lists the installed packs (`language packs: typescript 0.1.0`). Without the pack,
|
|
16
|
+
`terrain calls` reads the same files with the core's own tokenizer.
|
|
17
|
+
|
|
18
|
+
Why install it: it is both more accurate and faster than the tokenizer. It parses the full syntax
|
|
19
|
+
and resolves names through scopes, and a cold read takes about 0.6 to 0.7 times the tokenizer's time,
|
|
20
|
+
since a tree-sitter query hands the reader only the nodes it needs. tree-sitter loads on the first
|
|
21
|
+
front-end file read, so commands that read none barely notice the pack.
|
|
22
|
+
|
|
23
|
+
The facts are the core's: `<receiver>.<verb>(path, ...)`, `fetch(path, {method})` and object literals
|
|
24
|
+
with a `url:` path. The syntax tree makes strings, templates, comments, type arguments and assertions,
|
|
25
|
+
spreads and computed keys exact, and names resolve through scopes: a name is read from the declaration
|
|
26
|
+
it refers to, so a `const url` inside a function is that function's, and a parameter hides a module
|
|
27
|
+
constant of the same name only inside its function. A file tree-sitter cannot parse whole is reported
|
|
28
|
+
with the lines of its syntax errors, and a call inside one counts as unresolved.
|
|
29
|
+
|
|
30
|
+
It depends on `tree-sitter` and the TypeScript and JavaScript grammars, which ship prebuilt wheels
|
|
31
|
+
for Linux, macOS and Windows; the versions are pinned exactly, since grammars rename nodes between
|
|
32
|
+
releases. The pack only parses the text it is given: it reads no other file and runs nothing.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codeterrain-typescript"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "TypeScript and JavaScript for codeterrain: front-end HTTP calls read with tree-sitter."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Mitbolas GmbH" }]
|
|
13
|
+
# Exact pins: tree-sitter and its grammars version separately and rename nodes between
|
|
14
|
+
# releases, and the pack's version is what keys its cached facts.
|
|
15
|
+
dependencies = [
|
|
16
|
+
"codeterrain>=0.3",
|
|
17
|
+
"tree-sitter==0.26.0",
|
|
18
|
+
"tree-sitter-typescript==0.23.2",
|
|
19
|
+
"tree-sitter-javascript==0.25.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.entry-points."codeterrain.languages"]
|
|
23
|
+
typescript = "codeterrain_typescript:language"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/benchli-sports/codeterrain"
|
|
27
|
+
Issues = "https://github.com/benchli-sports/codeterrain/issues"
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
codeterrain = { workspace = true }
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/codeterrain_typescript"]
|
|
@@ -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,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import codeterrain_typescript
|
|
11
|
+
import pytest
|
|
12
|
+
from codeterrain_typescript import language, read_calls
|
|
13
|
+
|
|
14
|
+
from codeterrain import calls_static, languages
|
|
15
|
+
from codeterrain.cli import main
|
|
16
|
+
|
|
17
|
+
CORE_TESTS = Path(__file__).parents[3] / "tests" / "test_calls_static.py"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calls(source: str, suffix: str = ".tsx") -> list[tuple[str, str, int, str]]:
|
|
21
|
+
facts = read_calls(source, suffix)
|
|
22
|
+
assert facts["error"] is None
|
|
23
|
+
return [
|
|
24
|
+
(c["method"], c["path"] or "?" + c["raw"], c["line"], c["style"]) for c in facts["calls"]
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- the core's tokenizer cases ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
#: Cases of the core's tokenizer whose expectation this reader changes, each asserted below.
|
|
31
|
+
CHANGED = {
|
|
32
|
+
# scopes: each function's own `const url` is read
|
|
33
|
+
"test_a_constant_declared_twice_with_different_values_is_not_resolved",
|
|
34
|
+
# `'/v1' as string + '/items'` is a sum of two strings, read whole
|
|
35
|
+
"test_an_assertion_followed_by_more_expression_leaves_the_constant_unresolved",
|
|
36
|
+
# unterminated text and an invalid escape are syntax errors, reported with their lines
|
|
37
|
+
"test_an_unterminated_string_or_template_does_not_raise",
|
|
38
|
+
"test_escapes_in_strings_and_templates_are_decoded",
|
|
39
|
+
# the parser is linear: thousands of nested calls are read, not refused
|
|
40
|
+
"test_thousands_of_nested_calls_are_refused_quickly_not_read_in_quadratic_time",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _core_cases() -> list[tuple[str, object]]:
|
|
45
|
+
spec = importlib.util.spec_from_file_location("core_calls_cases", CORE_TESTS)
|
|
46
|
+
assert spec is not None and spec.loader is not None
|
|
47
|
+
module = importlib.util.module_from_spec(spec)
|
|
48
|
+
spec.loader.exec_module(module)
|
|
49
|
+
module.parse_source = lambda source: read_calls(source, ".tsx")
|
|
50
|
+
return [
|
|
51
|
+
(name, fn) for name, fn in inspect.getmembers(module, inspect.isfunction)
|
|
52
|
+
if name.startswith("test_") and not inspect.signature(fn).parameters
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.skipif(not CORE_TESTS.is_file(), reason="run from a checkout of codeterrain")
|
|
57
|
+
def test_the_core_tokenizer_cases_hold_except_the_changed_ones():
|
|
58
|
+
cases = _core_cases()
|
|
59
|
+
assert CHANGED <= {name for name, _ in cases}
|
|
60
|
+
for name, case in cases:
|
|
61
|
+
if name not in CHANGED:
|
|
62
|
+
case()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_scopes_read_each_function_s_own_constant():
|
|
66
|
+
source = (
|
|
67
|
+
"function a() {\n const url = '/a';\n return fetch(url);\n}\n"
|
|
68
|
+
"function b() {\n const url = '/b';\n return fetch(url);\n}\n"
|
|
69
|
+
"const SAME = '/same';\nconst SAME = '/same';\nfetch(SAME);\n"
|
|
70
|
+
)
|
|
71
|
+
assert calls(source) == [
|
|
72
|
+
("GET", "/a", 3, "fetch"), ("GET", "/b", 7, "fetch"), ("GET", "/same", 11, "fetch"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_an_assertion_inside_a_sum_is_read_through():
|
|
77
|
+
source = (
|
|
78
|
+
"const endpoint = '/v1' as string + '/items';\n"
|
|
79
|
+
"const pick = '/v1' as string ? '/v2' : '/v3';\n"
|
|
80
|
+
"fetch(endpoint);\n"
|
|
81
|
+
"fetch(pick);\n"
|
|
82
|
+
)
|
|
83
|
+
assert calls(source) == [("GET", "/v1/items", 3, "fetch"), ("GET", "?pick", 4, "fetch")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_syntax_error_is_reported_and_a_call_in_it_is_unresolved():
|
|
87
|
+
for source in ("apiClient.get('/v1/x\n", "fetch(`/v1/${a", "apiClient.get<"):
|
|
88
|
+
facts = read_calls(source, ".tsx")
|
|
89
|
+
assert facts["error"].startswith("syntax errors at line 1")
|
|
90
|
+
assert all(not c["resolved"] for c in facts["calls"])
|
|
91
|
+
facts = read_calls("fetch('/v1/ok');\nfetch('/v1/\\uZZZZ');\n", ".ts")
|
|
92
|
+
assert facts["error"] == "syntax errors at line 2; calls there are unresolved"
|
|
93
|
+
assert [(c["path"], c["resolved"]) for c in facts["calls"]] == [
|
|
94
|
+
("/v1/ok", True), ("", False),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_escapes_are_decoded():
|
|
99
|
+
source = (
|
|
100
|
+
"fetch('/v1/\\u0069tems');\n"
|
|
101
|
+
"fetch(\"/v1/\\x6frders\");\n"
|
|
102
|
+
"fetch(`/v1/\\u{6c}ines/${id}`);\n"
|
|
103
|
+
"fetch('/v1/a\\\n"
|
|
104
|
+
"b');\n"
|
|
105
|
+
)
|
|
106
|
+
assert calls(source) == [
|
|
107
|
+
("GET", "/v1/items", 1, "fetch"),
|
|
108
|
+
("GET", "/v1/orders", 2, "fetch"),
|
|
109
|
+
("GET", "/v1/lines/{}", 3, "fetch"),
|
|
110
|
+
("GET", "/v1/ab", 4, "fetch"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_thousands_of_nested_calls_are_read_in_linear_time():
|
|
115
|
+
nested = "function f() { return " + "api.get(" * 3000 + "'/v1/x'" + ")" * 3000 + "; }\n"
|
|
116
|
+
started = time.perf_counter()
|
|
117
|
+
facts = read_calls(nested, ".ts")
|
|
118
|
+
assert facts["error"] is None and len(facts["calls"]) == 3000
|
|
119
|
+
assert [c["path"] for c in facts["calls"] if c["resolved"]] == ["/v1/x"]
|
|
120
|
+
assert time.perf_counter() - started < 5
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- scopes --------------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_a_constant_inside_an_interpolated_function_is_that_function_s():
|
|
127
|
+
# The acceptance case of the tree-sitter reader.
|
|
128
|
+
source = (
|
|
129
|
+
'const url = "/outer";\n'
|
|
130
|
+
'const s = `${(() => { const url = "/inner"; return fetch(url); })()}`;\n'
|
|
131
|
+
"fetch(url);\n"
|
|
132
|
+
)
|
|
133
|
+
assert calls(source) == [("GET", "/inner", 2, "fetch"), ("GET", "/outer", 3, "fetch")]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_a_parameter_or_block_binding_hides_a_constant_only_where_it_is_bound():
|
|
137
|
+
source = (
|
|
138
|
+
"const url = '/v1/default';\n"
|
|
139
|
+
"export function load(url: string) { return fetch(url); }\n"
|
|
140
|
+
"fetch(url);\n"
|
|
141
|
+
"if (ok) { const url = '/v1/block'; fetch(url); }\n"
|
|
142
|
+
"for (const url of urls) { fetch(url); }\n"
|
|
143
|
+
"try { go(); } catch (url) { fetch(url); }\n"
|
|
144
|
+
"fetch(url);\n"
|
|
145
|
+
)
|
|
146
|
+
assert calls(source) == [
|
|
147
|
+
("GET", "?url", 2, "fetch"),
|
|
148
|
+
("GET", "/v1/default", 3, "fetch"),
|
|
149
|
+
("GET", "/v1/block", 4, "fetch"),
|
|
150
|
+
("GET", "?url", 5, "fetch"),
|
|
151
|
+
("GET", "?url", 6, "fetch"),
|
|
152
|
+
("GET", "/v1/default", 7, "fetch"),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_a_var_belongs_to_its_function_and_an_import_is_not_a_constant():
|
|
157
|
+
source = (
|
|
158
|
+
"import { BASE } from './config';\n"
|
|
159
|
+
"import API from './api';\n"
|
|
160
|
+
"const url = '/v1/outer';\n"
|
|
161
|
+
"function load() {\n"
|
|
162
|
+
" if (x) { var url = '/v1/var'; }\n"
|
|
163
|
+
" return fetch(url);\n"
|
|
164
|
+
"}\n"
|
|
165
|
+
"fetch(`${BASE}/items`);\n"
|
|
166
|
+
"fetch(`${API}/orders`);\n"
|
|
167
|
+
"function each() { for (var url of urls) {} fetch(url); }\n"
|
|
168
|
+
"function keys() { for (var url in map) {} fetch(url); }\n"
|
|
169
|
+
"function scoped() { for (let url of urls) {} fetch(url); }\n"
|
|
170
|
+
)
|
|
171
|
+
assert calls(source) == [
|
|
172
|
+
("GET", "?url", 6, "fetch"),
|
|
173
|
+
("GET", "/items", 8, "fetch"),
|
|
174
|
+
("GET", "/orders", 9, "fetch"),
|
|
175
|
+
("GET", "?url", 10, "fetch"),
|
|
176
|
+
("GET", "?url", 11, "fetch"),
|
|
177
|
+
("GET", "/v1/outer", 12, "fetch"),
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_a_method_held_in_a_constant_is_read():
|
|
182
|
+
source = (
|
|
183
|
+
"const POST = 'post';\n"
|
|
184
|
+
"fetch('/v1/a', { method: POST });\n"
|
|
185
|
+
"axios({ url: '/v1/b', method: 'purge' });\n"
|
|
186
|
+
)
|
|
187
|
+
assert calls(source) == [("POST", "/v1/a", 2, "fetch"), ("?", "/v1/b", 3, "object")]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- grammars ------------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_each_suffix_is_read_with_its_grammar():
|
|
194
|
+
# `<T>value` is an assertion in .ts and an element in .tsx; JSX in a .js file.
|
|
195
|
+
assert calls("const u = <string>'/v1/a';\nfetch(u);\n", ".ts") == [("GET", "/v1/a", 2, "fetch")]
|
|
196
|
+
assert calls(
|
|
197
|
+
"const El = () => <button onClick={() => fetch('/v1/b')}>Don't</button>;\n", ".js"
|
|
198
|
+
) == [("GET", "/v1/b", 1, "fetch")]
|
|
199
|
+
for suffix in language.call_suffixes:
|
|
200
|
+
assert calls("api.post('/v1/c', body);\n", suffix) == [("POST", "/v1/c", 1, "client")]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_an_awaited_call_with_type_arguments_is_read():
|
|
204
|
+
# The grammar reads `await api.get<T>(...)` as a call of `await api.get`.
|
|
205
|
+
source = (
|
|
206
|
+
"const load = async (id: string) => {\n"
|
|
207
|
+
" const r = await apiClient.get<Result>(`/v1/teams/${id}/tasks`);\n"
|
|
208
|
+
" const s = await fetch<Other>('/v1/b');\n"
|
|
209
|
+
" return (await getClient()).get<T>('/v1/c');\n"
|
|
210
|
+
"};\n"
|
|
211
|
+
)
|
|
212
|
+
assert calls(source, ".ts") == [
|
|
213
|
+
("GET", "/v1/teams/{}/tasks", 2, "client"), ("GET", "/v1/b", 3, "fetch"),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_a_bare_ampersand_in_jsx_text_is_text_not_a_syntax_error():
|
|
218
|
+
source = (
|
|
219
|
+
"const Page = () => (\n"
|
|
220
|
+
" <div onClick={() => fetch('/v1/a')}>\n"
|
|
221
|
+
" <p>Staff & privilege levels</p>\n"
|
|
222
|
+
" {render({ url: '/v1/b', label: <b>Retry & cancel</b> })}\n"
|
|
223
|
+
" </div>\n"
|
|
224
|
+
");\n"
|
|
225
|
+
)
|
|
226
|
+
assert calls(source) == [("GET", "/v1/a", 2, "fetch"), ("GET", "/v1/b", 4, "object")]
|
|
227
|
+
assert calls(source, ".jsx") == [("GET", "/v1/a", 2, "fetch"), ("GET", "/v1/b", 4, "object")]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_discovering_the_pack_does_not_load_tree_sitter():
|
|
231
|
+
# Every terrain command discovers the packs; only the ones reading front-end files need the
|
|
232
|
+
# parser.
|
|
233
|
+
import subprocess
|
|
234
|
+
import sys
|
|
235
|
+
|
|
236
|
+
code = (
|
|
237
|
+
"import sys, codeterrain_typescript as p; assert 'tree_sitter' not in sys.modules; "
|
|
238
|
+
"p.read_calls('fetch(\"/v1/a\");', '.ts'); assert 'tree_sitter' in sys.modules"
|
|
239
|
+
)
|
|
240
|
+
subprocess.run([sys.executable, "-c", code], check=True)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --- bounds --------------------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_a_file_over_the_size_bound_is_not_read():
|
|
247
|
+
facts = read_calls("x" * (codeterrain_typescript.MAX_FILE_BYTES + 1), ".ts")
|
|
248
|
+
assert facts == {"calls": [], "error": "file larger than 2000000 bytes; not read"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_a_constant_that_doubles_at_every_declaration_stays_cheap_and_the_bound_holds(
|
|
252
|
+
monkeypatch,
|
|
253
|
+
):
|
|
254
|
+
lines = ["const p0 = '/a';"] + [f"const p{n} = p{n - 1} + p{n - 1};" for n in range(1, 60)]
|
|
255
|
+
source = "\n".join([*lines, "fetch(p59);", "fetch(p3);"]) + "\n"
|
|
256
|
+
assert calls(source, ".ts") == [("GET", "?p59", 61, "fetch"), ("GET", "/a" * 8, 62, "fetch")]
|
|
257
|
+
# past the bound, the file is an error, not a slow answer
|
|
258
|
+
monkeypatch.setattr(codeterrain_typescript, "WORK_FLOOR", 50)
|
|
259
|
+
monkeypatch.setattr(codeterrain_typescript, "WORK_PER_BYTE", 0)
|
|
260
|
+
facts = read_calls(source, ".ts")
|
|
261
|
+
assert facts["calls"] == [] and "work bound" in facts["error"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.parametrize("seed", range(4))
|
|
265
|
+
def test_random_and_hostile_input_never_raises(seed):
|
|
266
|
+
rng = random.Random(seed)
|
|
267
|
+
alphabet = "`'\"${}()[]<>/\\=+;:,.!?\n abcfetchurlgetpost0123" + chr(0) + "
\udcff"
|
|
268
|
+
for _ in range(50):
|
|
269
|
+
text = "".join(rng.choice(alphabet) for _ in range(rng.randrange(1, 400)))
|
|
270
|
+
for suffix in (".ts", ".tsx", ".js"):
|
|
271
|
+
facts = read_calls(text, suffix)
|
|
272
|
+
assert set(facts) == {"calls", "error"}
|
|
273
|
+
assert all(calls_static._pack_call(c) for c in facts["calls"])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# --- through the core ----------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_the_core_reads_front_end_calls_through_the_pack(tmp_path, capsys):
|
|
280
|
+
(found,) = [lang for lang in languages.discover()[0] if lang.name == "typescript"]
|
|
281
|
+
assert found.read is None and set(found.call_suffixes) == set(calls_static.SUFFIXES)
|
|
282
|
+
root = tmp_path / "web"
|
|
283
|
+
(root / "src").mkdir(parents=True)
|
|
284
|
+
(root / "src" / "orders.ts").write_text(
|
|
285
|
+
"function a() { const url = '/v1/a'; return fetch(url); }\n"
|
|
286
|
+
"function b() { const url = '/v1/b'; return fetch(url); }\n"
|
|
287
|
+
"fetch(`/v1/${\n",
|
|
288
|
+
encoding="utf-8",
|
|
289
|
+
)
|
|
290
|
+
code = main(["calls", "--json", "--root", str(root), "--cache-dir", str(tmp_path / "c")])
|
|
291
|
+
data = json.loads(capsys.readouterr().out)
|
|
292
|
+
assert code == 0
|
|
293
|
+
assert [(c["path"], c["line"]) for c in data["rows"] if c["resolved"]] == [
|
|
294
|
+
("/v1/a", 1), ("/v1/b", 2),
|
|
295
|
+
]
|
|
296
|
+
assert data["errors"] == [
|
|
297
|
+
{"file": "src/orders.ts", "error": "syntax errors at line 3; calls there are unresolved"},
|
|
298
|
+
]
|