gitnexus 1.6.10 → 1.6.11-rc.2
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.
- package/dist/core/group/extractors/http-patterns/kotlin.js +522 -7
- package/dist/core/group/extractors/http-patterns/php.js +279 -11
- package/dist/core/index-freshness.d.ts +2 -2
- package/dist/core/index-freshness.js +13 -0
- package/dist/core/ingestion/parsing-processor.d.ts +2 -0
- package/dist/core/ingestion/parsing-processor.js +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +7 -0
- package/dist/core/ingestion/pipeline-phases/parse.d.ts +4 -0
- package/dist/core/ingestion/pipeline.js +4 -1
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +377 -0
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +1203 -0
- package/dist/core/ingestion/scope-extractor-bridge.js +8 -2
- package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +14 -2
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +11 -1
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.d.ts +14 -0
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.js +35 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +6 -0
- package/dist/core/ingestion/workers/parse-worker.js +7 -1
- package/dist/core/ingestion/workers/result-merge.js +4 -1
- package/dist/core/run-analyze.js +6 -0
- package/dist/mcp/local/local-backend.d.ts +2 -0
- package/dist/mcp/local/local-backend.js +29 -0
- package/dist/mcp/tools.js +6 -4
- package/dist/storage/parse-cache.js +6 -6
- package/dist/storage/repo-meta.d.ts +17 -1
- package/dist/storage/repo-meta.js +1 -1
- package/dist/types/pipeline.d.ts +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kotlin binding for the language-agnostic constant resolver (#2391 core).
|
|
3
|
+
*
|
|
4
|
+
* Supplies the two Kotlin-specific pieces — {@link resolveKotlinImport} (import
|
|
5
|
+
* specifier → file, honoring JVM package rules) and
|
|
6
|
+
* {@link extractKotlinModuleConstants} (tree → {@link ModuleConstants}) — plus
|
|
7
|
+
* the folding entry points {@link resolveKotlinConstant} and
|
|
8
|
+
* {@link foldKotlinOperands}, so callers stay language-oblivious.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IS ACTUALLY SHARED WITH THE AGNOSTIC CORE. One value —
|
|
11
|
+
* {@link MAX_FOLD_LENGTH} — and five types. The core's own `resolveConstant` /
|
|
12
|
+
* `resolveOperands` are NOT called: the fold state machine below (cycle guard,
|
|
13
|
+
* success memo, depth caps, operand concatenation — roughly 200 of this file's
|
|
14
|
+
* lines) is a local fork, close enough to `java-const-resolver.ts`'s already
|
|
15
|
+
* forked copy that the two read as the same code with the language name
|
|
16
|
+
* swapped.
|
|
17
|
+
*
|
|
18
|
+
* That fork is a consequence, not an oversight. The core keys its maps by
|
|
19
|
+
* SIMPLE name, and a Kotlin operand can be a QUALIFIED reference at any
|
|
20
|
+
* position (`X = ApiPaths.Y + "/tail"`); handed to the core, `ApiPaths.Y` misses
|
|
21
|
+
* every map and floors the whole chain to null — see {@link computeKotlinFold},
|
|
22
|
+
* which resolves operands through the qualified-aware walk for exactly this
|
|
23
|
+
* reason. The import chase is Kotlin-specific too: a member import is spelled
|
|
24
|
+
* identically to a type import, so {@link resolveImportedName} has to try both
|
|
25
|
+
* readings, and the core exposes no hook for that. Java forked first on the same
|
|
26
|
+
* grounds. Teaching the core qualified names, and retiring both copies against
|
|
27
|
+
* it, is the standing follow-up; until then the honest description of this file
|
|
28
|
+
* is "a second fork", not "a binding over a shared fold".
|
|
29
|
+
*
|
|
30
|
+
* Kotlin shares the JVM package/import model with Java, so this binding mirrors
|
|
31
|
+
* `java-const-resolver.ts` in structure, naming and skip-floor discipline. The
|
|
32
|
+
* four places Kotlin genuinely differs are handled explicitly, not translated:
|
|
33
|
+
*
|
|
34
|
+
* 1. **Where a constant can live.** Java has one carrier (`static final` on a
|
|
35
|
+
* type). Kotlin has three: a top-level `const val`/`val`, a member of an
|
|
36
|
+
* `object`, and a member of a `companion object` — the last is referenced
|
|
37
|
+
* through its ENCLOSING class (`Holder.NAME`), not through `Companion`.
|
|
38
|
+
* 2. **No `String` type gate.** Kotlin infers property types, so
|
|
39
|
+
* `const val ORDERS = "/orders"` carries no type node to check. The
|
|
40
|
+
* initializer decides: anything {@link parseKotlinConstOperands} cannot fold
|
|
41
|
+
* to a string (a number, a call, a template) drops the constant.
|
|
42
|
+
* 3. **File names and directories are free.** `object ApiPaths` may live in
|
|
43
|
+
* `Constants.kt`, and a file's `package` need not match the directory it
|
|
44
|
+
* sits in, so a `<package>/<Name>.kt` PATH lookup is a convention and not a
|
|
45
|
+
* rule. The authority is each file's DECLARED `package`, which
|
|
46
|
+
* {@link extractKotlinModuleConstants} records and {@link resolveKotlinImport}
|
|
47
|
+
* requires an exact match on; the path is only a tie-break among files that
|
|
48
|
+
* already declare the right package.
|
|
49
|
+
* 4. **Member imports are unmarked.** Java spells them `import static a.b.C.F`;
|
|
50
|
+
* Kotlin writes `import a.b.C.F`, which is byte-identical to a type import
|
|
51
|
+
* of a class `F` in package `a.b.C`. Nothing in the syntax says which, so
|
|
52
|
+
* the fold tries both readings (see `resolveImportedName`) instead of
|
|
53
|
+
* guessing from casing.
|
|
54
|
+
* 5. **Any identifier may be backtick-quoted.** `` package com.example.`api` ``
|
|
55
|
+
* and `package com.example.api` are the SAME package to the compiler, and a
|
|
56
|
+
* keyword segment (`` com.example.`fun` ``) can only be spelled the quoted
|
|
57
|
+
* way. The grammar keeps the backticks in the node text, so every identifier is
|
|
58
|
+
* read through {@link unquoteKotlinIdentifier} before it becomes a map key
|
|
59
|
+
* or a lookup name — see that function for what a verbatim comparison cost.
|
|
60
|
+
*
|
|
61
|
+
* THREE PLACES THIS BINDING NO LONGER MIRRORS JAVA, each because the mirrored
|
|
62
|
+
* behavior was wrong rather than merely different, and each open as a Java
|
|
63
|
+
* follow-up rather than fixed here:
|
|
64
|
+
*
|
|
65
|
+
* * `java-const-resolver.ts` flattens nested types into one file-level
|
|
66
|
+
* namespace and argues the collision away — "qualified refs carry the class
|
|
67
|
+
* name, so nesting only matters for same-name fields, which flatten
|
|
68
|
+
* last-wins". The argument does not hold: the collision is one level BELOW
|
|
69
|
+
* the qualification, in the initializer, so a fully qualified `A.ROUTE` whose
|
|
70
|
+
* initializer names a bare sibling `BASE` still resolves through whichever
|
|
71
|
+
* same-named sibling was walked last. {@link extractKotlinModuleConstants}
|
|
72
|
+
* keys by Kotlin's own visibility instead.
|
|
73
|
+
* * Java's import resolution can lean on the `<package>/<Name>.java` layout the
|
|
74
|
+
* language enforces. Kotlin's cannot, and inferring the package from the path
|
|
75
|
+
* lets a path-suffix twin outrank the real declaration — so
|
|
76
|
+
* {@link resolveKotlinImport} reads the declared `package` instead.
|
|
77
|
+
* * Java's fold entry points take a file and a name, because a Java `static
|
|
78
|
+
* final` reachable by simple name is reachable that way from anywhere in the
|
|
79
|
+
* file. A Kotlin COMPANION member is not: it is bound unqualified only inside
|
|
80
|
+
* its enclosing class body. {@link foldKotlinOperands} therefore also takes
|
|
81
|
+
* the enclosing type chain of the reference site, which is what lets the
|
|
82
|
+
* binding answer a bare reference by Kotlin's scoping rather than by "whoever
|
|
83
|
+
* was walked last" — see {@link qualifyKotlinRefInEnclosingTypes}.
|
|
84
|
+
*
|
|
85
|
+
* Constant shapes this binding harvests:
|
|
86
|
+
*
|
|
87
|
+
* const val TOP_LEVEL = "/api/v1" // file top level
|
|
88
|
+
* object ApiPaths { // object member
|
|
89
|
+
* const val BASE = "/api/v1"
|
|
90
|
+
* val ORDERS = BASE + "/orders"
|
|
91
|
+
* }
|
|
92
|
+
* class Holder { companion object { const val H = "/h" } } // → Holder.H
|
|
93
|
+
*
|
|
94
|
+
* Reference shapes at annotation sites this binding resolves:
|
|
95
|
+
* @PostMapping(ApiPaths.ORDERS) // qualified
|
|
96
|
+
* @PostMapping(com.example.app.api.ApiPaths.ORDERS) // FQN-qualified
|
|
97
|
+
* @PostMapping(ORDERS) // single-name import
|
|
98
|
+
* @PostMapping(ApiPaths.BASE + "/orders") // inline concat
|
|
99
|
+
*
|
|
100
|
+
* Which ANNOTATIONS count as routes is a separate question this module has no
|
|
101
|
+
* say in — `spring-shared.ts` owns that map. Folding and annotation recognition
|
|
102
|
+
* compose; neither implies the other.
|
|
103
|
+
*
|
|
104
|
+
* Keying (parity with the Java and Python bindings): the repo map is keyed by
|
|
105
|
+
* unique POSIX file path, and an import that cannot be pinned to exactly one
|
|
106
|
+
* file returns null (skip floor), never a wrong path. A missing route is a
|
|
107
|
+
* missing fact; a wrongly folded one is a false edge in the graph. "Exactly one
|
|
108
|
+
* file" is decided from the DECLARED package, not from the path: a path is a
|
|
109
|
+
* repository-layout accident that any decoy directory can imitate, whereas the
|
|
110
|
+
* `package` header is the declaration the compiler itself resolves against.
|
|
111
|
+
*
|
|
112
|
+
* POSIX keys are a PRECONDITION this module cannot check cheaply, so it is
|
|
113
|
+
* enforced at the one boundary that produces them: `http-patterns/kotlin.ts`
|
|
114
|
+
* normalizes separators on both the write side (the `prepareRepo` map keys) and
|
|
115
|
+
* the read side (`scan`'s `fileRel`). It has to, because the orchestrator's file
|
|
116
|
+
* list comes from glob v13, which has no `posix: true` and joins with the
|
|
117
|
+
* platform separator — so on Windows the keys arrive backslashed and every
|
|
118
|
+
* `<pkg>/<Name>.kt` test in {@link resolveKotlinImport} would miss, silently
|
|
119
|
+
* disabling cross-file folding on that platform alone. Normalizing INSIDE this
|
|
120
|
+
* module instead cannot work: the resolver returns the key it matched, and a
|
|
121
|
+
* normalized return value would then miss in a map that was never normalized.
|
|
122
|
+
*
|
|
123
|
+
* WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group
|
|
124
|
+
* extractor (`group/extractors/http-patterns/java.ts`) and the ingestion
|
|
125
|
+
* provider (`languages/java.ts`, via `extractModuleConstants` +
|
|
126
|
+
* `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because
|
|
127
|
+
* the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over
|
|
128
|
+
* `decoratorRoutes` — and `languages/kotlin.ts` declares no
|
|
129
|
+
* `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`)
|
|
130
|
+
* is bound to `tree-sitter-java` and its node types. Declaring the constant
|
|
131
|
+
* hooks on the Kotlin provider today would harvest a map on every Kotlin file
|
|
132
|
+
* that nothing consumes. An ingestion-side Kotlin route extractor is the
|
|
133
|
+
* prerequisite; when it lands, this binding is what its provider hooks should
|
|
134
|
+
* point at, and no change here is needed.
|
|
135
|
+
*/
|
|
136
|
+
import { unquoteSpringLiteral } from './spring-shared.js';
|
|
137
|
+
import { MAX_FOLD_LENGTH, } from './constant-resolver.js';
|
|
138
|
+
/**
|
|
139
|
+
* The declared `package` of the file `mc` describes, or null when the entry did
|
|
140
|
+
* not come from {@link extractKotlinModuleConstants} and therefore cannot be
|
|
141
|
+
* matched against an import specifier.
|
|
142
|
+
*/
|
|
143
|
+
function declaredPackageOf(mc) {
|
|
144
|
+
const declared = mc?.packageName;
|
|
145
|
+
return typeof declared === 'string' ? declared : null;
|
|
146
|
+
}
|
|
147
|
+
const NO_UNFOLDABLE_DECLARATIONS = new Set();
|
|
148
|
+
/**
|
|
149
|
+
* Kotlin declaration keys known to exist but not fold, or an empty set when
|
|
150
|
+
* `mc` came from another language binding.
|
|
151
|
+
*/
|
|
152
|
+
export function unfoldableDeclarationsOf(mc) {
|
|
153
|
+
const declarations = mc?.unfoldableDeclarations;
|
|
154
|
+
return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS;
|
|
155
|
+
}
|
|
156
|
+
/** Source extensions a Kotlin declaration can live in. */
|
|
157
|
+
const KOTLIN_EXTENSIONS = ['.kt', '.kts'];
|
|
158
|
+
/**
|
|
159
|
+
* The name a backtick-quoted Kotlin identifier denotes: `` `api` `` → `api`.
|
|
160
|
+
*
|
|
161
|
+
* Quotes are spelling, not part of the name. tree-sitter-kotlin keeps them in
|
|
162
|
+
* node text, so every identifier that becomes a map key or lookup is read
|
|
163
|
+
* through here. Applied per dot-separated segment — a quoted identifier cannot
|
|
164
|
+
* contain `.`. Both the declaration side ({@link declaredPackage}) and the
|
|
165
|
+
* import side ({@link resolveKotlinImport}) are normalized, because either may
|
|
166
|
+
* carry the quotes while the other spells the same name plainly.
|
|
167
|
+
*/
|
|
168
|
+
export function unquoteKotlinIdentifier(text) {
|
|
169
|
+
return text.length >= 2 && text.startsWith('`') && text.endsWith('`') ? text.slice(1, -1) : text;
|
|
170
|
+
}
|
|
171
|
+
/** {@link unquoteKotlinIdentifier} applied to every segment of a dotted name. */
|
|
172
|
+
function unquoteKotlinDottedName(text) {
|
|
173
|
+
return text.includes('`') ? text.split('.').map(unquoteKotlinIdentifier).join('.') : text;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Recursion ceiling for {@link parseKotlinConstOperands}, counted in `+` links.
|
|
177
|
+
*
|
|
178
|
+
* This bounds SYNTAX depth, not resolution: `A + B + C` nests one
|
|
179
|
+
* `additive_expression` per link, so the cap is really "how long a concatenation
|
|
180
|
+
* may one initializer be". Deliberately loose — generated route tables do
|
|
181
|
+
* concatenate a dozen fragments, and overrunning costs a skipped route, so the
|
|
182
|
+
* cap is a guard against pathological input rather than a statement about
|
|
183
|
+
* reasonable code.
|
|
184
|
+
*/
|
|
185
|
+
const MAX_OPERAND_PARSE_DEPTH = 64;
|
|
186
|
+
/**
|
|
187
|
+
* Recursion ceiling for the fold, counted in REFERENCE hops (`A = B`, `B = C`).
|
|
188
|
+
*
|
|
189
|
+
* Larger than the agnostic core's own `MAX_RESOLVE_DEPTH` (8), which is
|
|
190
|
+
* module-private in `constant-resolver.ts` and therefore cannot simply be
|
|
191
|
+
* reused, and equal to the value the Java binding spells inline. It backstops
|
|
192
|
+
* the cycle guard, which terminates loops but not a long acyclic chain; the
|
|
193
|
+
* memo makes reaching it cheap. Both caps floor to null, i.e. to a skipped
|
|
194
|
+
* route.
|
|
195
|
+
*/
|
|
196
|
+
const MAX_FOLD_DEPTH = 32;
|
|
197
|
+
/**
|
|
198
|
+
* Cheap content gate: can this Kotlin file DEFINE a string constant that a route
|
|
199
|
+
* annotation might reference?
|
|
200
|
+
*
|
|
201
|
+
* Exported so every caller uses the same predicate and none can disagree with
|
|
202
|
+
* {@link extractKotlinModuleConstants} about which files carry constants — the
|
|
203
|
+
* defect class the Java binding's shared `isJavaConstantFile` exists to prevent.
|
|
204
|
+
*
|
|
205
|
+
* Arms, all intended to be WIDER than the extractor (a gate may over-admit — it
|
|
206
|
+
* only costs a parse — while rejecting a file the extractor would accept costs a
|
|
207
|
+
* fact):
|
|
208
|
+
* - `const val NAME [: T] =`. `const` is legal only at a file's top level or in
|
|
209
|
+
* an `object`/`companion object`, i.e. exactly the carriers the extractor
|
|
210
|
+
* harvests, so this arm needs no scope check.
|
|
211
|
+
* - an `object` (or `companion object`) declaration together with a `val NAME =`
|
|
212
|
+
* binding. A non-`const` `val` is the other half of the extractor's input and
|
|
213
|
+
* carries no keyword of its own; requiring an `object` nearby keeps a file
|
|
214
|
+
* whose only `val`s are function locals from costing a parse. It still admits
|
|
215
|
+
* a top-level `val` in a file that happens to declare an object elsewhere,
|
|
216
|
+
* which is the harmless direction.
|
|
217
|
+
* - a `val NAME =` binding at file scope. A small lexical walk tracks braces
|
|
218
|
+
* and parentheses while skipping comments and literals, admitting the
|
|
219
|
+
* top-level non-`const` property the extractor harvests without turning every
|
|
220
|
+
* function-local `val` or constructor property into an extra parse.
|
|
221
|
+
*
|
|
222
|
+
* Both name arms accept a BACKTICK-QUOTED identifier as well as a bare one,
|
|
223
|
+
* because the extractor does: `unquoteKotlinIdentifier` strips the quoting
|
|
224
|
+
* everywhere a name becomes a key, so `const val \`ORDERS\` = "/orders"` is a
|
|
225
|
+
* constant this module resolves. A gate that matched only `\w+` rejected the
|
|
226
|
+
* file outright and the reference floored to skip — a gate narrower than the
|
|
227
|
+
* extractor, which is the one direction the arms above are meant to exclude.
|
|
228
|
+
*/
|
|
229
|
+
const KOTLIN_NAME = String.raw `(?:\w+|\`[^\`\n]+\`)`;
|
|
230
|
+
const CONST_VAL_AT = new RegExp(String.raw `const\s+val\s+${KOTLIN_NAME}(?=\s|:|=|$)`, 'y');
|
|
231
|
+
const VAL_DECLARATION_AT = new RegExp(String.raw `val\s+${KOTLIN_NAME}(?=\s|:|=|by\b|$)`, 'y');
|
|
232
|
+
/** Is `source[index...]` the keyword `word`, rather than part of an identifier? */
|
|
233
|
+
function keywordAt(source, index, word) {
|
|
234
|
+
if (!source.startsWith(word, index))
|
|
235
|
+
return false;
|
|
236
|
+
const before = index === 0 ? '' : source[index - 1];
|
|
237
|
+
const after = source[index + word.length] ?? '';
|
|
238
|
+
return !/[\w$]/.test(before) && !/[\w$]/.test(after);
|
|
239
|
+
}
|
|
240
|
+
/** Test a sticky declaration pattern at one source offset without slicing. */
|
|
241
|
+
function declarationAt(pattern, source, index) {
|
|
242
|
+
pattern.lastIndex = index;
|
|
243
|
+
return pattern.test(source);
|
|
244
|
+
}
|
|
245
|
+
export function isKotlinConstantFile(source) {
|
|
246
|
+
let braces = 0;
|
|
247
|
+
let parens = 0;
|
|
248
|
+
let blockCommentDepth = 0;
|
|
249
|
+
let sawObject = false;
|
|
250
|
+
for (let i = 0; i < source.length; i++) {
|
|
251
|
+
if (blockCommentDepth > 0) {
|
|
252
|
+
if (source.startsWith('/*', i)) {
|
|
253
|
+
blockCommentDepth++;
|
|
254
|
+
i++;
|
|
255
|
+
}
|
|
256
|
+
else if (source.startsWith('*/', i)) {
|
|
257
|
+
blockCommentDepth--;
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (source.startsWith('//', i)) {
|
|
263
|
+
const newline = source.indexOf('\n', i + 2);
|
|
264
|
+
if (newline < 0)
|
|
265
|
+
break;
|
|
266
|
+
i = newline;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (source.startsWith('/*', i)) {
|
|
270
|
+
blockCommentDepth = 1;
|
|
271
|
+
i++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const quote = source[i];
|
|
275
|
+
if (source.startsWith('"""', i)) {
|
|
276
|
+
const end = source.indexOf('"""', i + 3);
|
|
277
|
+
if (end < 0)
|
|
278
|
+
break;
|
|
279
|
+
i = end + 2;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (quote === '"' || quote === "'") {
|
|
283
|
+
for (i++; i < source.length; i++) {
|
|
284
|
+
if (source[i] === '\\') {
|
|
285
|
+
i++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (source[i] === quote)
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (quote === '`') {
|
|
294
|
+
const end = source.indexOf('`', i + 1);
|
|
295
|
+
if (end < 0)
|
|
296
|
+
break;
|
|
297
|
+
i = end;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (quote === '{') {
|
|
301
|
+
braces++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (quote === '}') {
|
|
305
|
+
braces = Math.max(0, braces - 1);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (quote === '(') {
|
|
309
|
+
parens++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (quote === ')') {
|
|
313
|
+
parens = Math.max(0, parens - 1);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (keywordAt(source, i, 'object')) {
|
|
317
|
+
sawObject = true;
|
|
318
|
+
i += 'object'.length - 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (keywordAt(source, i, 'const') && declarationAt(CONST_VAL_AT, source, i))
|
|
322
|
+
return true;
|
|
323
|
+
if (keywordAt(source, i, 'val') && declarationAt(VAL_DECLARATION_AT, source, i)) {
|
|
324
|
+
if (sawObject || (braces === 0 && parens === 0))
|
|
325
|
+
return true;
|
|
326
|
+
i += 'val'.length - 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
/** Does `key` name the file `<asPath>.kt` / `<asPath>.kts`? */
|
|
332
|
+
function isFileNamedAfterDeclaration(key, asPath) {
|
|
333
|
+
for (const ext of KOTLIN_EXTENSIONS) {
|
|
334
|
+
const candidate = `${asPath}${ext}`;
|
|
335
|
+
if (key === candidate || key.endsWith(`/${candidate}`))
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Does the file `mc` describes declare a top-level entity called `name` — an
|
|
342
|
+
* `object`/companion carrier whose members are keyed `name.<MEMBER>`, or a
|
|
343
|
+
* top-level constant keyed `name` outright?
|
|
344
|
+
*
|
|
345
|
+
* A true result is strong enough to select a unique declaring file before path
|
|
346
|
+
* fallbacks: the tested key set is a superset of every local key the fold may
|
|
347
|
+
* subsequently read for that imported name. Two matching files are therefore
|
|
348
|
+
* ambiguous; one is authoritative. A miss falls back to the conservative path
|
|
349
|
+
* heuristics below, whose result is still verified by the actual map lookup.
|
|
350
|
+
*/
|
|
351
|
+
function declaresTopLevelName(mc, name) {
|
|
352
|
+
const prefix = `${name}.`;
|
|
353
|
+
for (const map of [mc.literals, mc.exprs]) {
|
|
354
|
+
if (map.has(name))
|
|
355
|
+
return true;
|
|
356
|
+
for (const key of map.keys())
|
|
357
|
+
if (key.startsWith(prefix))
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
for (const key of unfoldableDeclarationsOf(mc)) {
|
|
361
|
+
if (key === name || key.startsWith(prefix))
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
/** Does this file contribute declarations to Kotlin import ambiguity? */
|
|
367
|
+
function contributesKotlinConstants(mc) {
|
|
368
|
+
return mc.literals.size > 0 || mc.exprs.size > 0 || unfoldableDeclarationsOf(mc).size > 0;
|
|
369
|
+
}
|
|
370
|
+
/** Top-level names declared by one file (`Outer.X` contributes `Outer`). */
|
|
371
|
+
function topLevelDeclarationNames(mc) {
|
|
372
|
+
const names = new Set();
|
|
373
|
+
for (const map of [mc.literals, mc.exprs]) {
|
|
374
|
+
for (const key of map.keys()) {
|
|
375
|
+
const dot = key.indexOf('.');
|
|
376
|
+
names.add(dot < 0 ? key : key.slice(0, dot));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const key of unfoldableDeclarationsOf(mc)) {
|
|
380
|
+
const dot = key.indexOf('.');
|
|
381
|
+
names.add(dot < 0 ? key : key.slice(0, dot));
|
|
382
|
+
}
|
|
383
|
+
return names;
|
|
384
|
+
}
|
|
385
|
+
/** Every declaration key recorded for a file, foldable or not. */
|
|
386
|
+
function declarationKeys(mc) {
|
|
387
|
+
return new Set([...mc.literals.keys(), ...mc.exprs.keys(), ...unfoldableDeclarationsOf(mc)]);
|
|
388
|
+
}
|
|
389
|
+
/** Build the immutable import projections once for a repo constant map. */
|
|
390
|
+
export function buildKotlinConstantIndex(repo) {
|
|
391
|
+
const constantKeys = new Set();
|
|
392
|
+
const byFqn = new Map();
|
|
393
|
+
const mutablePackages = new Map();
|
|
394
|
+
for (const [key, mc] of repo) {
|
|
395
|
+
if (!contributesKotlinConstants(mc))
|
|
396
|
+
continue;
|
|
397
|
+
constantKeys.add(key);
|
|
398
|
+
const packageName = declaredPackageOf(mc);
|
|
399
|
+
if (packageName === null)
|
|
400
|
+
continue;
|
|
401
|
+
let bucket = mutablePackages.get(packageName);
|
|
402
|
+
if (!bucket) {
|
|
403
|
+
bucket = { files: [], declarers: new Map() };
|
|
404
|
+
mutablePackages.set(packageName, bucket);
|
|
405
|
+
}
|
|
406
|
+
bucket.files.push(key);
|
|
407
|
+
for (const name of topLevelDeclarationNames(mc)) {
|
|
408
|
+
if (!bucket.declarers.has(name))
|
|
409
|
+
bucket.declarers.set(name, key);
|
|
410
|
+
else if (bucket.declarers.get(name) !== key)
|
|
411
|
+
bucket.declarers.set(name, null);
|
|
412
|
+
}
|
|
413
|
+
for (const declaration of declarationKeys(mc)) {
|
|
414
|
+
const parts = declaration.split('.');
|
|
415
|
+
// A member key `Outer.Inner.Q` proves the file declares both owner paths
|
|
416
|
+
// as well as the member itself. This lets imports of nested objects and
|
|
417
|
+
// their members retain the complete in-file lookup path.
|
|
418
|
+
for (let length = 1; length <= parts.length; length++) {
|
|
419
|
+
const localName = parts.slice(0, length).join('.');
|
|
420
|
+
const fqn = packageName === '' ? localName : `${packageName}.${localName}`;
|
|
421
|
+
const existing = byFqn.get(fqn);
|
|
422
|
+
if (existing === undefined)
|
|
423
|
+
byFqn.set(fqn, { fileKey: key, localName });
|
|
424
|
+
else if (existing !== null && existing.fileKey !== key)
|
|
425
|
+
byFqn.set(fqn, null);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { repo, constantKeys, byPackage: mutablePackages, byFqn };
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Add one scan-time file without rebuilding the base index when it only imports
|
|
433
|
+
* constants. A newly discovered declaration is rare and rebuilds once for that
|
|
434
|
+
* file's scan, never once per route.
|
|
435
|
+
*/
|
|
436
|
+
export function overlayKotlinConstantIndex(index, fileKey, mc) {
|
|
437
|
+
const repo = new Map(index.repo);
|
|
438
|
+
const replacing = repo.has(fileKey);
|
|
439
|
+
repo.set(fileKey, mc);
|
|
440
|
+
if (!replacing && !contributesKotlinConstants(mc))
|
|
441
|
+
return { ...index, repo };
|
|
442
|
+
return buildKotlinConstantIndex(repo);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Map a fully-qualified import specifier to the unique file key it refers to, or
|
|
446
|
+
* null when it cannot be pinned to exactly one file.
|
|
447
|
+
*
|
|
448
|
+
* A specifier is split at its last dot into the package it names and the
|
|
449
|
+
* declaration inside it (`com.example.app.api` + `ApiPaths`). Resolution then
|
|
450
|
+
* runs in three steps, all of them "unique or nothing":
|
|
451
|
+
*
|
|
452
|
+
* 0. **Declared package** — only files whose `package` header is EXACTLY the
|
|
453
|
+
* sought package can carry the declaration (compared after
|
|
454
|
+
* {@link unquoteKotlinIdentifier}, since backtick quoting is spelling and
|
|
455
|
+
* not identity). This is the authority, and it
|
|
456
|
+
* is checked first. Kotlin does not require a file's directory to match its
|
|
457
|
+
* package, so the reverse test — "does this path end with the package?" —
|
|
458
|
+
* answers a different question, one any decoy directory can satisfy: a file
|
|
459
|
+
* at `src/x/com/example/api/ApiPaths.kt` declaring `package x.com.example.api`
|
|
460
|
+
* is not `com.example.api.ApiPaths` and must never be folded as it, and a
|
|
461
|
+
* path-suffix test also lets a root-level `package data` be impersonated by
|
|
462
|
+
* `…/com/example/data/`. An entry with no recorded package is rejected, not
|
|
463
|
+
* assumed to be the default package.
|
|
464
|
+
* 1. **Declared name** — when exactly one file declares the sought name, use
|
|
465
|
+
* it. When two do, the FQN itself is duplicated in the repository and names
|
|
466
|
+
* no single declaration, so return null. This is the general form of the
|
|
467
|
+
* same-FQN check step 2 could only make for files that happen to follow the
|
|
468
|
+
* file-name convention, and it is what stops a `src/test/…` copy of a
|
|
469
|
+
* production constant from being folded into a production route.
|
|
470
|
+
* 2. **File named after the declaration** — when declaration metadata found no
|
|
471
|
+
* owner, try the package-matching file ending
|
|
472
|
+
* `com/example/app/api/ApiPaths.kt`. Kotlin does not require this (`object
|
|
473
|
+
* ApiPaths` may live in `Constants.kt`), so it is only a fallback candidate;
|
|
474
|
+
* the subsequent map lookup must still prove that it carries the value.
|
|
475
|
+
* 3. **Sole file in the package** — when declaration metadata cannot identify
|
|
476
|
+
* the name, use the unique package-matching candidate. The set passed in
|
|
477
|
+
* contains files with foldable or explicitly unfoldable declarations, so
|
|
478
|
+
* unrelated files cannot create ambiguity once step 1 identifies a unique
|
|
479
|
+
* declarer. With 2+ unidentified candidates it returns null.
|
|
480
|
+
*
|
|
481
|
+
* Steps 2 and 3 can still hand back a file that does not declare the wanted name
|
|
482
|
+
* (its package is right and it is the only candidate, but the name lives
|
|
483
|
+
* elsewhere or nowhere). That remains safe by construction: the fold looks the
|
|
484
|
+
* name up in that file's map, misses, and returns null.
|
|
485
|
+
*
|
|
486
|
+
* A "nearest shared directory" tie-break is deliberately NOT applied when a step
|
|
487
|
+
* has several candidates, for the reason the Java binding records: the JVM
|
|
488
|
+
* resolves duplicate FQNs by classpath order, not directory proximity, so a test
|
|
489
|
+
* fixture copy sitting closer in the tree can outrank the real dependency and
|
|
490
|
+
* yield a silently wrong literal. In a resolver whose whole contract is
|
|
491
|
+
* skip-or-correct, a plausible guess is the one answer that cannot be allowed.
|
|
492
|
+
*
|
|
493
|
+
* This can no longer be typed as the agnostic {@link ModuleConstants} consumer's
|
|
494
|
+
* `ImportResolver`, whose signature carries only file KEYS: deciding a candidate
|
|
495
|
+
* on its declared package needs the map those keys index. Nothing is lost — the
|
|
496
|
+
* core's own fold is not used here either (see the module header), and the
|
|
497
|
+
* alternative is a resolver that must guess from a path.
|
|
498
|
+
*/
|
|
499
|
+
export function resolveKotlinImport(_importingFileKey, rawModuleSpec, candidateKeys, repo) {
|
|
500
|
+
// Normalized here as well as at extraction, so the function answers the same
|
|
501
|
+
// question however a caller spells the specifier.
|
|
502
|
+
const moduleSpec = unquoteKotlinDottedName(rawModuleSpec);
|
|
503
|
+
const lastDot = moduleSpec.lastIndexOf('.');
|
|
504
|
+
const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot);
|
|
505
|
+
const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1);
|
|
506
|
+
// Step 0 + step 1 in one pass over the candidates.
|
|
507
|
+
const inPackage = [];
|
|
508
|
+
let declaring = null;
|
|
509
|
+
for (const key of candidateKeys) {
|
|
510
|
+
const mc = repo.get(key);
|
|
511
|
+
if (!mc || declaredPackageOf(mc) !== packageName)
|
|
512
|
+
continue;
|
|
513
|
+
inPackage.push(key);
|
|
514
|
+
if (declaresTopLevelName(mc, simpleName)) {
|
|
515
|
+
if (declaring !== null)
|
|
516
|
+
return null; // 2+ files declare this FQN
|
|
517
|
+
declaring = key;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (inPackage.length === 0)
|
|
521
|
+
return null;
|
|
522
|
+
if (declaring !== null)
|
|
523
|
+
return declaring;
|
|
524
|
+
if (inPackage.length === 1)
|
|
525
|
+
return inPackage[0]; // steps 2 and 3 agree
|
|
526
|
+
// Step 2: the file-name convention, as a tie-break among valid candidates.
|
|
527
|
+
const asPath = moduleSpec.replace(/\./g, '/');
|
|
528
|
+
let named = null;
|
|
529
|
+
for (const key of inPackage) {
|
|
530
|
+
if (!isFileNamedAfterDeclaration(key, asPath))
|
|
531
|
+
continue;
|
|
532
|
+
if (named !== null)
|
|
533
|
+
return null; // 2+ files spell the convention
|
|
534
|
+
named = key;
|
|
535
|
+
}
|
|
536
|
+
// Step 3 is "the sole candidate", already returned above.
|
|
537
|
+
return named;
|
|
538
|
+
}
|
|
539
|
+
/** Indexed equivalent of {@link resolveKotlinImport}, with identical fallbacks. */
|
|
540
|
+
export function resolveKotlinImportWithIndex(rawModuleSpec, index) {
|
|
541
|
+
return resolveKotlinImportTarget(rawModuleSpec, index)?.fileKey ?? null;
|
|
542
|
+
}
|
|
543
|
+
/** Resolve an import to both its file and complete in-file declaration path. */
|
|
544
|
+
function resolveKotlinImportTarget(rawModuleSpec, index) {
|
|
545
|
+
const moduleSpec = unquoteKotlinDottedName(rawModuleSpec);
|
|
546
|
+
const lastDot = moduleSpec.lastIndexOf('.');
|
|
547
|
+
const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot);
|
|
548
|
+
const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1);
|
|
549
|
+
const bucket = index.byPackage.get(packageName);
|
|
550
|
+
// Preserve the top-level interpretation whenever the exact declared package
|
|
551
|
+
// exists. A parent package may legally contain nested objects whose joined
|
|
552
|
+
// path spells the same FQN; letting that projection win would make a nested
|
|
553
|
+
// decoy override the real top-level declaration.
|
|
554
|
+
if (!bucket)
|
|
555
|
+
return index.byFqn.get(moduleSpec) ?? null;
|
|
556
|
+
if (bucket.declarers.has(simpleName)) {
|
|
557
|
+
const fileKey = bucket.declarers.get(simpleName);
|
|
558
|
+
return fileKey === null || fileKey === undefined ? null : { fileKey, localName: simpleName };
|
|
559
|
+
}
|
|
560
|
+
if (bucket.files.length === 1)
|
|
561
|
+
return { fileKey: bucket.files[0], localName: simpleName };
|
|
562
|
+
const asPath = moduleSpec.replace(/\./g, '/');
|
|
563
|
+
let named = null;
|
|
564
|
+
for (const key of bucket.files) {
|
|
565
|
+
if (!isFileNamedAfterDeclaration(key, asPath))
|
|
566
|
+
continue;
|
|
567
|
+
if (named !== null)
|
|
568
|
+
return null;
|
|
569
|
+
named = key;
|
|
570
|
+
}
|
|
571
|
+
return named === null ? null : { fileKey: named, localName: simpleName };
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Is `node` a Kotlin string literal, and if so what value does the route layer
|
|
575
|
+
* give it?
|
|
576
|
+
*
|
|
577
|
+
* Two rejections, both floors rather than guesses:
|
|
578
|
+
* - **String templates.** `"$base/orders"` parses as a `string_literal` whose
|
|
579
|
+
* children include an interpolation alongside the `string_content` runs.
|
|
580
|
+
* Joining the content runs would silently DELETE the interpolated part and
|
|
581
|
+
* publish `/orders` — a path the application does not serve. Any named child
|
|
582
|
+
* that is not `string_content` means the value is not statically knowable, so
|
|
583
|
+
* the literal is refused. (The same test makes the function safe against a
|
|
584
|
+
* grammar that splits escape sequences into their own nodes: it would floor
|
|
585
|
+
* to skip, never to a de-escaped path.)
|
|
586
|
+
* - **Multi-line raw strings.** A single-line `"""/api"""` is exact — unlike a
|
|
587
|
+
* Java text block, a Kotlin raw string performs no escape processing and no
|
|
588
|
+
* incidental-indentation stripping, so it folds to precisely its content. A
|
|
589
|
+
* multi-line one carries newlines (and usually a `.trimIndent()` call this
|
|
590
|
+
* layer cannot fold), so it is refused.
|
|
591
|
+
*
|
|
592
|
+
* Otherwise the quotes are sliced off the RAW TEXT via
|
|
593
|
+
* {@link unquoteSpringLiteral} — the same function the literal path uses — so
|
|
594
|
+
* `@GetMapping(ApiPaths.USER_REGEX)` and `@GetMapping("/user/{id:\\d+}")` emit
|
|
595
|
+
* the same path for the same Kotlin source.
|
|
596
|
+
*/
|
|
597
|
+
function stringLiteralValue(node) {
|
|
598
|
+
if (node.type !== 'string_literal')
|
|
599
|
+
return null;
|
|
600
|
+
for (const child of node.namedChildren) {
|
|
601
|
+
if (child.type !== 'string_content')
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
const raw = node.text;
|
|
605
|
+
if (raw.startsWith('"""') && raw.includes('\n'))
|
|
606
|
+
return null;
|
|
607
|
+
return unquoteSpringLiteral(raw);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Flatten a navigation expression (`ApiPaths`, `com.example.app.ApiPaths`) to
|
|
611
|
+
* its dotted text, or null when any segment is not a plain identifier (calls,
|
|
612
|
+
* `this`, indexing, safe navigation — not a constant shape).
|
|
613
|
+
*/
|
|
614
|
+
function flattenNavigation(node) {
|
|
615
|
+
if (node.type === 'simple_identifier')
|
|
616
|
+
return unquoteKotlinIdentifier(node.text);
|
|
617
|
+
if (node.type === 'navigation_expression') {
|
|
618
|
+
const target = node.namedChild(0);
|
|
619
|
+
const suffix = node.namedChildren.find((c) => c.type === 'navigation_suffix');
|
|
620
|
+
const field = suffix?.namedChildren.find((c) => c.type === 'simple_identifier');
|
|
621
|
+
if (target && field) {
|
|
622
|
+
const head = flattenNavigation(target);
|
|
623
|
+
return head === null ? null : `${head}.${unquoteKotlinIdentifier(field.text)}`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Parse a Kotlin constant initializer (or an inline annotation argument) into an
|
|
630
|
+
* operand list, or null when it is not a foldable string expression. Handles a
|
|
631
|
+
* bare string literal, a bare identifier (`X = Y`), a qualified reference
|
|
632
|
+
* (`X = ApiPaths.Y` — recorded as ONE ref named `ApiPaths.Y`), and
|
|
633
|
+
* left-associative `+` chains of the three. Everything else — numbers, calls,
|
|
634
|
+
* `when`/`if` expressions, templates, `buildString` — returns null, which makes
|
|
635
|
+
* the constant unresolvable (→ skip floor), never a wrong value.
|
|
636
|
+
*
|
|
637
|
+
* A chain nests: tree-sitter-kotlin parses `A + B + C` as
|
|
638
|
+
* `additive_expression(additive_expression(A, B), C)`, so every node here has
|
|
639
|
+
* exactly two operands and arbitrary-length chains fold by recursion. The same
|
|
640
|
+
* node type also carries `-`, which is not a string operation, so a `+` token
|
|
641
|
+
* must be present.
|
|
642
|
+
*
|
|
643
|
+
* A PARENTHESIZED operand (`(A + B) + "/c"`) is deliberately NOT unwrapped,
|
|
644
|
+
* matching `parseJavaConstOperands`, which has no parenthesis arm either. The
|
|
645
|
+
* shape is vanishingly rare in a route annotation and the cost of omitting it is
|
|
646
|
+
* a skipped route, not a wrong one; adding it to both bindings at once is the
|
|
647
|
+
* only way to keep them in parity, so it is left to a follow-up.
|
|
648
|
+
*/
|
|
649
|
+
export function parseKotlinConstOperands(node, depth = 0) {
|
|
650
|
+
if (!node)
|
|
651
|
+
return null;
|
|
652
|
+
if (depth > MAX_OPERAND_PARSE_DEPTH)
|
|
653
|
+
return null;
|
|
654
|
+
if (node.type === 'string_literal') {
|
|
655
|
+
const value = stringLiteralValue(node);
|
|
656
|
+
return value === null ? null : [{ kind: 'literal', value }];
|
|
657
|
+
}
|
|
658
|
+
if (node.type === 'simple_identifier') {
|
|
659
|
+
return [{ kind: 'ref', name: unquoteKotlinIdentifier(node.text) }];
|
|
660
|
+
}
|
|
661
|
+
if (node.type === 'navigation_expression') {
|
|
662
|
+
const name = flattenNavigation(node);
|
|
663
|
+
return name === null ? null : [{ kind: 'ref', name }];
|
|
664
|
+
}
|
|
665
|
+
// `additive_expression` covers both `+` and `-` in tree-sitter-kotlin; only a
|
|
666
|
+
// `+` chain concatenates strings.
|
|
667
|
+
if (node.type === 'additive_expression') {
|
|
668
|
+
if (!(node.children ?? []).some((c) => c.type === '+'))
|
|
669
|
+
return null;
|
|
670
|
+
const operandNodes = node.namedChildren;
|
|
671
|
+
if (operandNodes.length !== 2)
|
|
672
|
+
return null;
|
|
673
|
+
const left = parseKotlinConstOperands(operandNodes[0], depth + 1);
|
|
674
|
+
const right = parseKotlinConstOperands(operandNodes[1], depth + 1);
|
|
675
|
+
if (left === null || right === null)
|
|
676
|
+
return null;
|
|
677
|
+
return [...left, ...right];
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
/** The `val`/`var` keyword a property declaration binds with, or null. */
|
|
682
|
+
function bindingKind(property) {
|
|
683
|
+
return property.children.find((c) => c.type === 'binding_pattern_kind')?.text ?? null;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The initializer expression of a property declaration, or null when it has
|
|
687
|
+
* none.
|
|
688
|
+
*
|
|
689
|
+
* Reads the `=` that is a DIRECT child of the `property_declaration`, so a
|
|
690
|
+
* custom getter (`val X: String get() = "/g"`, whose `=` lives under `getter`)
|
|
691
|
+
* and a delegate (`val X by lazy { … }`, which has no `=` at all) both yield
|
|
692
|
+
* null. Both are computed at access time and are not constants.
|
|
693
|
+
*/
|
|
694
|
+
function initializerOf(property) {
|
|
695
|
+
let equalsIndex = -1;
|
|
696
|
+
for (let i = 0; i < property.childCount; i++) {
|
|
697
|
+
if (property.child(i)?.type === '=') {
|
|
698
|
+
equalsIndex = i;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (equalsIndex < 0)
|
|
703
|
+
return null;
|
|
704
|
+
for (let i = equalsIndex + 1; i < property.childCount; i++) {
|
|
705
|
+
const child = property.child(i);
|
|
706
|
+
if (child?.isNamed)
|
|
707
|
+
return child;
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* The file's declared `package`, or `''` when it declares none (default
|
|
713
|
+
* package). Shaped exactly like the import walk below: `package_header` holds
|
|
714
|
+
* one `identifier` whose `simple_identifier` children are the dotted segments.
|
|
715
|
+
*
|
|
716
|
+
* Each segment is unquoted (see {@link unquoteKotlinIdentifier}), so a package
|
|
717
|
+
* declared `` com.example.`api` `` is recorded — and therefore matched — as the
|
|
718
|
+
* same package an import spells `com.example.api`.
|
|
719
|
+
*/
|
|
720
|
+
function declaredPackage(root) {
|
|
721
|
+
const header = root.children.find((c) => c.type === 'package_header');
|
|
722
|
+
const identifier = header?.children.find((c) => c.type === 'identifier');
|
|
723
|
+
if (!identifier)
|
|
724
|
+
return '';
|
|
725
|
+
return identifier.namedChildren
|
|
726
|
+
.filter((c) => c.type === 'simple_identifier')
|
|
727
|
+
.map((c) => unquoteKotlinIdentifier(c.text))
|
|
728
|
+
.join('.');
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Extract the declared package, file-level string constants and import bindings
|
|
732
|
+
* of one parsed Kotlin file into the {@link KotlinModuleConstants} shape the
|
|
733
|
+
* resolver consumes.
|
|
734
|
+
*
|
|
735
|
+
* Constants come from the three carriers Kotlin allows a caller to reach without
|
|
736
|
+
* an instance: file top level, `object` members, and `companion object` members.
|
|
737
|
+
* A `val` in a plain class or interface body is per-instance or abstract and is
|
|
738
|
+
* NOT collected — the Kotlin analogue of Java's `static final` requirement. `var`
|
|
739
|
+
* is rejected outright.
|
|
740
|
+
*
|
|
741
|
+
* KEYS FOLLOW KOTLIN'S OWN VISIBILITY, not a flattened namespace. Every constant
|
|
742
|
+
* is recorded under `<DeclaringType>.<NAME>`, the spelling a qualified reference
|
|
743
|
+
* uses, with a companion member keyed under its ENCLOSING CLASS (`Holder.NAME`)
|
|
744
|
+
* because that is how Kotlin source refers to it — `Companion` never appears in
|
|
745
|
+
* a reference. The SIMPLE name is recorded only for a TOP-LEVEL `val`, the one
|
|
746
|
+
* carrier whose bare binding really does span the file. A member of a named
|
|
747
|
+
* `object` gets no bare key, because `BASE` alone does not name `A.BASE` from
|
|
748
|
+
* anywhere outside `object A`'s own body. Writing one anyway (as this binding
|
|
749
|
+
* and the Java one both used to) fabricates a binding the language does not
|
|
750
|
+
* have, and a fabricated key outranks the genuine `import com.example.api.Paths.ORDERS`
|
|
751
|
+
* that {@link computeKotlinFold} consults only after literals and expressions.
|
|
752
|
+
*
|
|
753
|
+
* A COMPANION member gets no bare key either: it is bound unqualified inside
|
|
754
|
+
* its enclosing class body and nowhere else. {@link qualifyKotlinRefInEnclosingTypes}
|
|
755
|
+
* rewrites a bare name to `<EnclosingType>.<NAME>` when an enclosing type
|
|
756
|
+
* declares it, so the companion wins inside its own class and loses everywhere
|
|
757
|
+
* else.
|
|
758
|
+
*
|
|
759
|
+
* An initializer that names a SIBLING is resolved the same way, against its own
|
|
760
|
+
* scope chain, innermost first, before the file level: inside
|
|
761
|
+
* `object A { const val BASE = "/right"; const val ROUTE = BASE + "/m" }` the
|
|
762
|
+
* operand `BASE` is rewritten to `A.BASE`. Collecting every declaration before
|
|
763
|
+
* recording any keeps that independent of declaration order.
|
|
764
|
+
*
|
|
765
|
+
* A TOP-LEVEL initializer has an EMPTY scope chain, so its bare operands stay
|
|
766
|
+
* bare and resolve at file level — they must not pick up a companion key.
|
|
767
|
+
*
|
|
768
|
+
* A non-foldable rebind (`X = compute()`) DROPS X to unresolvable rather than
|
|
769
|
+
* leaving a stale literal — and drops a same-named import with it whenever the
|
|
770
|
+
* declaration shadows that import ANYWHERE (top level, or a companion inside its
|
|
771
|
+
* class). The import map has no scopes, so a companion's shadow is applied
|
|
772
|
+
* file-wide: the conservative direction, costing a route rather than publishing
|
|
773
|
+
* the imported value at a reference the compiler binds to the unfoldable member.
|
|
774
|
+
* An `object` member shadows nothing and must leave the import alone.
|
|
775
|
+
*/
|
|
776
|
+
export function extractKotlinModuleConstants(tree) {
|
|
777
|
+
const literals = new Map();
|
|
778
|
+
const exprs = new Map();
|
|
779
|
+
const imports = new Map();
|
|
780
|
+
const unfoldableDeclarations = new Set();
|
|
781
|
+
// Pass 1: imports.
|
|
782
|
+
const walkImports = (node) => {
|
|
783
|
+
if (node.type === 'import_header') {
|
|
784
|
+
// `import a.b.*` binds no single name — nothing to key the fold on, and
|
|
785
|
+
// guessing which package member a bare reference came from is exactly the
|
|
786
|
+
// wrong answer. Skipped, so such a reference floors to skip.
|
|
787
|
+
const isWildcard = node.children.some((c) => c.type === 'wildcard_import');
|
|
788
|
+
const identifier = node.children.find((c) => c.type === 'identifier');
|
|
789
|
+
if (!isWildcard && identifier) {
|
|
790
|
+
const segments = identifier.namedChildren
|
|
791
|
+
.filter((c) => c.type === 'simple_identifier')
|
|
792
|
+
.map((c) => unquoteKotlinIdentifier(c.text));
|
|
793
|
+
if (segments.length >= 2) {
|
|
794
|
+
const spec = segments.join('.');
|
|
795
|
+
const originalName = segments[segments.length - 1];
|
|
796
|
+
const aliasNode = node.children
|
|
797
|
+
.find((c) => c.type === 'import_alias')
|
|
798
|
+
?.namedChildren.find((c) => c.type === 'type_identifier');
|
|
799
|
+
const alias = aliasNode ? unquoteKotlinIdentifier(aliasNode.text) : undefined;
|
|
800
|
+
// `module` is the specifier AS WRITTEN, complete. Kotlin does not mark
|
|
801
|
+
// member imports, so the fold — not the extractor — decides whether the
|
|
802
|
+
// trailing segment is a declaration or one of its members.
|
|
803
|
+
imports.set(alias ?? originalName, { module: spec, originalName });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
for (const child of node.children ?? [])
|
|
809
|
+
walkImports(child);
|
|
810
|
+
};
|
|
811
|
+
walkImports(tree.rootNode);
|
|
812
|
+
// Pass 2a: collect every declaration, writing nothing yet. Which member each
|
|
813
|
+
// unqualified operand means depends on the whole file, so no key can be
|
|
814
|
+
// written — and no operand rewritten — until the last declaration is in.
|
|
815
|
+
const declarations = [];
|
|
816
|
+
/** Declaring scope → the simple names it declares, foldable or not. */
|
|
817
|
+
const membersByScope = new Map();
|
|
818
|
+
const collectProperties = (body, declaringType, scopes, fileLevelName, shadowsImport) => {
|
|
819
|
+
for (const member of body.children ?? []) {
|
|
820
|
+
if (member.type !== 'property_declaration')
|
|
821
|
+
continue;
|
|
822
|
+
if (bindingKind(member) !== 'val')
|
|
823
|
+
continue;
|
|
824
|
+
const declaration = member.children.find((c) => c.type === 'variable_declaration');
|
|
825
|
+
const nameNode = declaration?.namedChildren.find((c) => c.type === 'simple_identifier');
|
|
826
|
+
if (!nameNode)
|
|
827
|
+
continue;
|
|
828
|
+
const name = unquoteKotlinIdentifier(nameNode.text);
|
|
829
|
+
if (declaringType !== null) {
|
|
830
|
+
let members = membersByScope.get(declaringType);
|
|
831
|
+
if (!members)
|
|
832
|
+
membersByScope.set(declaringType, (members = new Set()));
|
|
833
|
+
// Recorded even when the initializer does not fold: a sibling reference
|
|
834
|
+
// to an unfoldable member must resolve to that member and then MISS,
|
|
835
|
+
// not fall through to a same-named constant at file level.
|
|
836
|
+
members.add(name);
|
|
837
|
+
}
|
|
838
|
+
declarations.push({
|
|
839
|
+
name,
|
|
840
|
+
qualified: declaringType === null ? null : `${declaringType}.${name}`,
|
|
841
|
+
scopes,
|
|
842
|
+
fileLevelName,
|
|
843
|
+
shadowsImport,
|
|
844
|
+
operands: parseKotlinConstOperands(initializerOf(member)),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
const bodyOf = (node) => node.children.find((c) => c.type === 'class_body');
|
|
849
|
+
/** The declared name of an `object_declaration` / `class_declaration`. */
|
|
850
|
+
const typeNameOf = (node) => {
|
|
851
|
+
const ident = node.children.find((c) => c.type === 'type_identifier');
|
|
852
|
+
return ident ? unquoteKotlinIdentifier(ident.text) : null;
|
|
853
|
+
};
|
|
854
|
+
/** Append one simple type name to its enclosing qualified type path. */
|
|
855
|
+
const nestedTypeName = (enclosingType, name) => {
|
|
856
|
+
if (name === null)
|
|
857
|
+
return enclosingType;
|
|
858
|
+
return enclosingType === null ? name : `${enclosingType}.${name}`;
|
|
859
|
+
};
|
|
860
|
+
/** Prepend a qualified scope unless it is already the innermost scope. */
|
|
861
|
+
const withScope = (scope, scopes) => scope === null || scopes[0] === scope ? scopes : [scope, ...scopes];
|
|
862
|
+
const walkDeclarations = (node, enclosingType, scopes) => {
|
|
863
|
+
for (const child of node.children ?? []) {
|
|
864
|
+
if (child.type === 'object_declaration') {
|
|
865
|
+
const name = typeNameOf(child);
|
|
866
|
+
const body = bodyOf(child);
|
|
867
|
+
if (!body)
|
|
868
|
+
continue;
|
|
869
|
+
// Carry the full path: a nested object member is `Outer.Inner.NAME`, not
|
|
870
|
+
// `Inner.NAME`. Inside the body a bare name searches that qualified
|
|
871
|
+
// scope first, then each enclosing type.
|
|
872
|
+
const declaredType = nestedTypeName(enclosingType, name);
|
|
873
|
+
const inner = withScope(declaredType, scopes);
|
|
874
|
+
collectProperties(body, declaredType, inner, false, false);
|
|
875
|
+
walkDeclarations(body, declaredType, inner);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (child.type === 'companion_object') {
|
|
879
|
+
const body = bodyOf(child);
|
|
880
|
+
if (!body)
|
|
881
|
+
continue;
|
|
882
|
+
// Referenced through the enclosing class (`Holder.NAME`), never through
|
|
883
|
+
// `Companion` — so the qualified alias is keyed on `enclosingType`. The
|
|
884
|
+
// simple name is bound inside that class body only, which is a SCOPE and
|
|
885
|
+
// not a file-level key: it is reached from the reference site by
|
|
886
|
+
// `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`.
|
|
887
|
+
const inner = withScope(enclosingType, scopes);
|
|
888
|
+
collectProperties(body, enclosingType, inner, false, true);
|
|
889
|
+
walkDeclarations(body, enclosingType, inner);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
if (child.type === 'class_declaration') {
|
|
893
|
+
// A class/interface body's own `val`s are per-instance or abstract, so
|
|
894
|
+
// only its nested objects and companion contribute constants.
|
|
895
|
+
const name = typeNameOf(child);
|
|
896
|
+
const body = bodyOf(child);
|
|
897
|
+
const declaredType = nestedTypeName(enclosingType, name);
|
|
898
|
+
if (body)
|
|
899
|
+
walkDeclarations(body, declaredType, withScope(declaredType, scopes));
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
walkDeclarations(child, enclosingType, scopes);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
collectProperties(tree.rootNode, null, [], true, true);
|
|
906
|
+
walkDeclarations(tree.rootNode, null, []);
|
|
907
|
+
// Pass 2b: rewrite each initializer's unqualified operands against the scope
|
|
908
|
+
// chain that encloses it, then record. Only a top-level declaration writes a
|
|
909
|
+
// bare key, so nothing here can collide across scopes; a companion's
|
|
910
|
+
// unqualified binding is applied at the reference site instead.
|
|
911
|
+
// A PARTIALLY qualified reference is resolved here too, not just a bare one:
|
|
912
|
+
// inside `object Outer`, the initializer `Inner.Q + "/m"` names `Outer.Inner.Q`,
|
|
913
|
+
// and taking a dotted name as already complete looked up a key nothing
|
|
914
|
+
// declares. Split at the last dot and prefix the scope onto the OWNER, so the
|
|
915
|
+
// bare case (`ownerSuffix === null`) stays exactly what it was.
|
|
916
|
+
const qualifyRef = (refName, scopes) => {
|
|
917
|
+
const lastDot = refName.lastIndexOf('.');
|
|
918
|
+
const ownerSuffix = lastDot < 0 ? null : refName.slice(0, lastDot);
|
|
919
|
+
const member = lastDot < 0 ? refName : refName.slice(lastDot + 1);
|
|
920
|
+
for (const scope of scopes) {
|
|
921
|
+
const declaringType = ownerSuffix === null ? scope : `${scope}.${ownerSuffix}`;
|
|
922
|
+
if (membersByScope.get(declaringType)?.has(member))
|
|
923
|
+
return `${declaringType}.${member}`;
|
|
924
|
+
}
|
|
925
|
+
return refName; // file level, or unresolvable — the fold decides
|
|
926
|
+
};
|
|
927
|
+
for (const decl of declarations) {
|
|
928
|
+
const keys = [];
|
|
929
|
+
if (decl.fileLevelName)
|
|
930
|
+
keys.push(decl.name);
|
|
931
|
+
if (decl.qualified !== null)
|
|
932
|
+
keys.push(decl.qualified);
|
|
933
|
+
if (decl.operands === null) {
|
|
934
|
+
for (const key of keys) {
|
|
935
|
+
literals.delete(key);
|
|
936
|
+
exprs.delete(key);
|
|
937
|
+
unfoldableDeclarations.add(key);
|
|
938
|
+
}
|
|
939
|
+
if (decl.shadowsImport)
|
|
940
|
+
imports.delete(decl.name);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const operands = decl.operands.map((op) => op.kind === 'ref' ? { kind: 'ref', name: qualifyRef(op.name, decl.scopes) } : op);
|
|
944
|
+
const literalValue = operands.length === 1 && operands[0].kind === 'literal' ? operands[0].value : null;
|
|
945
|
+
for (const key of keys) {
|
|
946
|
+
unfoldableDeclarations.delete(key);
|
|
947
|
+
if (literalValue !== null) {
|
|
948
|
+
literals.set(key, literalValue);
|
|
949
|
+
exprs.delete(key);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
exprs.set(key, operands);
|
|
953
|
+
literals.delete(key);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
literals,
|
|
959
|
+
exprs,
|
|
960
|
+
imports,
|
|
961
|
+
packageName: declaredPackage(tree.rootNode),
|
|
962
|
+
unfoldableDeclarations,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function newFoldState(repo, index) {
|
|
966
|
+
return {
|
|
967
|
+
index: index ?? buildKotlinConstantIndex(repo),
|
|
968
|
+
visited: new Set(),
|
|
969
|
+
memo: new Map(),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Resolve a single Kotlin constant referenced in `fileKey` to its literal string
|
|
974
|
+
* value, folding `+` concatenation and following import chains via
|
|
975
|
+
* {@link resolveKotlinImport}, or null when it cannot be fully folded.
|
|
976
|
+
*
|
|
977
|
+
* `name` may be simple (`ORDERS`, resolved via a single-name import or a
|
|
978
|
+
* same-file constant) or qualified (`ApiPaths.ORDERS`, resolved via the type
|
|
979
|
+
* import plus the target file's qualified alias).
|
|
980
|
+
*/
|
|
981
|
+
export function resolveKotlinConstant(fileKey, name, repo, depth = 0, index) {
|
|
982
|
+
return resolveWithState(fileKey, name, newFoldState(repo, index), depth);
|
|
983
|
+
}
|
|
984
|
+
function resolveWithState(fileKey, name, state, depth) {
|
|
985
|
+
if (depth > MAX_FOLD_DEPTH)
|
|
986
|
+
return null;
|
|
987
|
+
const guard = `${fileKey}::${name}`;
|
|
988
|
+
const memoized = state.memo.get(guard);
|
|
989
|
+
if (memoized !== undefined)
|
|
990
|
+
return memoized;
|
|
991
|
+
if (state.visited.has(guard))
|
|
992
|
+
return null; // cycle: `name` is on the active stack
|
|
993
|
+
state.visited.add(guard);
|
|
994
|
+
try {
|
|
995
|
+
const result = computeKotlinFold(fileKey, name, state, depth);
|
|
996
|
+
if (result !== null)
|
|
997
|
+
state.memo.set(guard, result);
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
finally {
|
|
1001
|
+
state.visited.delete(guard);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Resolve a name bound by an import, trying both readings of the specifier.
|
|
1006
|
+
*
|
|
1007
|
+
* Kotlin writes a member import exactly like a type import, so
|
|
1008
|
+
* `import com.example.app.api.ApiPaths.ORDERS` is syntactically
|
|
1009
|
+
* indistinguishable from a type import of `ORDERS` in package
|
|
1010
|
+
* `com.example.app.api.ApiPaths`. Rather than guess from casing — a convention,
|
|
1011
|
+
* not a rule, and one that quietly breaks on `object apiPaths` or `const val
|
|
1012
|
+
* Orders` — both readings are attempted and the first that actually RESOLVES
|
|
1013
|
+
* wins. A reading that resolves to no constant simply falls through.
|
|
1014
|
+
*/
|
|
1015
|
+
function resolveImportedName(fileKey, imp, state, depth) {
|
|
1016
|
+
// Reading A: the specifier names the declaration itself (a top-level
|
|
1017
|
+
// `const val`, or a type whose file we then search).
|
|
1018
|
+
const direct = resolveKotlinImportTarget(imp.module, state.index);
|
|
1019
|
+
if (direct !== null) {
|
|
1020
|
+
const value = resolveWithState(direct.fileKey, direct.localName, state, depth);
|
|
1021
|
+
if (value !== null)
|
|
1022
|
+
return value;
|
|
1023
|
+
}
|
|
1024
|
+
// Reading B: the specifier names a MEMBER of the declaration one segment up
|
|
1025
|
+
// (`…ApiPaths.ORDERS` → member `ORDERS` of `ApiPaths`).
|
|
1026
|
+
const dot = imp.module.lastIndexOf('.');
|
|
1027
|
+
if (dot <= 0)
|
|
1028
|
+
return null;
|
|
1029
|
+
const ownerSpec = imp.module.slice(0, dot);
|
|
1030
|
+
const owner = resolveKotlinImportTarget(ownerSpec, state.index);
|
|
1031
|
+
if (owner === null)
|
|
1032
|
+
return null;
|
|
1033
|
+
return resolveWithState(owner.fileKey, `${owner.localName}.${imp.originalName}`, state, depth);
|
|
1034
|
+
}
|
|
1035
|
+
function computeKotlinFold(fileKey, name, state, depth) {
|
|
1036
|
+
const { repo } = state.index;
|
|
1037
|
+
// Qualified reference (`ApiPaths.ORDERS`): constants and imports are keyed by
|
|
1038
|
+
// their IN-FILE name, so a dotted name never hits directly. Split head.tail,
|
|
1039
|
+
// resolve the head through the importing file's type import, then look the
|
|
1040
|
+
// member up in the target file under its declaring name.
|
|
1041
|
+
//
|
|
1042
|
+
// Unlike the Java binding there is NO bare-`tail` fallback: in Kotlin
|
|
1043
|
+
// `Head.TAIL` means TAIL is a member of the object or companion `Head`, so a
|
|
1044
|
+
// top-level `TAIL` in the target file is a different declaration and matching
|
|
1045
|
+
// it would fabricate a value.
|
|
1046
|
+
const dot = name.indexOf('.');
|
|
1047
|
+
if (dot > 0) {
|
|
1048
|
+
const head = name.slice(0, dot);
|
|
1049
|
+
const tail = name.slice(dot + 1);
|
|
1050
|
+
const imp = repo.get(fileKey)?.imports.get(head);
|
|
1051
|
+
if (imp) {
|
|
1052
|
+
const target = resolveKotlinImportTarget(imp.module, state.index);
|
|
1053
|
+
if (target === null)
|
|
1054
|
+
return null;
|
|
1055
|
+
// `originalName` un-aliases `import … .ApiPaths as Paths`, so the lookup
|
|
1056
|
+
// uses the declaring type's real name.
|
|
1057
|
+
return resolveWithState(target.fileKey, `${target.localName}.${tail}`, state, depth + 1);
|
|
1058
|
+
}
|
|
1059
|
+
// Un-imported qualified name (FQN form `com.example.app.api.ApiPaths.ORDERS`):
|
|
1060
|
+
// try the longest dotted prefix that resolves to a file.
|
|
1061
|
+
const parts = name.split('.');
|
|
1062
|
+
for (let cut = parts.length - 2; cut >= 1; cut--) {
|
|
1063
|
+
const fqn = parts.slice(0, cut + 1).join('.');
|
|
1064
|
+
const target = resolveKotlinImportTarget(fqn, state.index);
|
|
1065
|
+
if (target !== null) {
|
|
1066
|
+
const member = parts.slice(cut + 1).join('.');
|
|
1067
|
+
return resolveWithState(target.fileKey, `${target.localName}.${member}`, state, depth + 1);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// No import bound the head and no FQN prefix resolved — fall through. A
|
|
1071
|
+
// dotted name is ALSO a valid key in this file's own maps, so a same-file
|
|
1072
|
+
// qualified reference (`ApiPaths.ORDERS` inside the file declaring
|
|
1073
|
+
// `object ApiPaths`) resolves below.
|
|
1074
|
+
}
|
|
1075
|
+
// Name lookup: literals, then same-file expressions, then the import chase.
|
|
1076
|
+
// Expressions are folded HERE rather than handed to the agnostic core because
|
|
1077
|
+
// an operand may itself be a QUALIFIED reference (`X = ApiPaths.Y + "/tail"`)
|
|
1078
|
+
// and the core only knows bare names: it would look `ApiPaths.Y` up in maps
|
|
1079
|
+
// keyed by simple name, miss, and floor the whole chain to null.
|
|
1080
|
+
const mc = repo.get(fileKey);
|
|
1081
|
+
if (!mc)
|
|
1082
|
+
return null;
|
|
1083
|
+
const literal = mc.literals.get(name);
|
|
1084
|
+
if (literal !== undefined)
|
|
1085
|
+
return literal;
|
|
1086
|
+
const expr = mc.exprs.get(name);
|
|
1087
|
+
if (expr !== undefined)
|
|
1088
|
+
return foldOperands(fileKey, expr, state, depth + 1);
|
|
1089
|
+
const imp = mc.imports.get(name);
|
|
1090
|
+
if (imp !== undefined)
|
|
1091
|
+
return resolveImportedName(fileKey, imp, state, depth + 1);
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Concatenate an operand list, resolving each `ref` through the qualified-aware
|
|
1096
|
+
* walk so `ApiPaths.BASE` works at every position, not just at the entry point.
|
|
1097
|
+
*
|
|
1098
|
+
* Bounded by {@link MAX_FOLD_LENGTH}: the depth cap bounds RECURSION but not
|
|
1099
|
+
* OUTPUT, which grows multiplicatively (`X = A + A; A = B + B; …`), so a
|
|
1100
|
+
* pathological chain would build a gigabyte-scale string before any cap fired.
|
|
1101
|
+
* Overrun floors to null.
|
|
1102
|
+
*/
|
|
1103
|
+
function foldOperands(fileKey, operands, state, depth) {
|
|
1104
|
+
let out = '';
|
|
1105
|
+
for (const op of operands) {
|
|
1106
|
+
if (op.kind === 'literal') {
|
|
1107
|
+
out += op.value;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
const piece = resolveWithState(fileKey, op.name, state, depth);
|
|
1111
|
+
if (piece === null)
|
|
1112
|
+
return null;
|
|
1113
|
+
out += piece;
|
|
1114
|
+
}
|
|
1115
|
+
if (out.length > MAX_FOLD_LENGTH)
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Rewrite one BARE reference to the enclosing type that binds it, or leave it
|
|
1122
|
+
* bare when none does — the reference-site twin of the `qualifyRef` that
|
|
1123
|
+
* {@link extractKotlinModuleConstants} applies to sibling initializers.
|
|
1124
|
+
*
|
|
1125
|
+
* `enclosingTypes` is the chain of qualified type paths the reference sits
|
|
1126
|
+
* inside, INNERMOST FIRST (`['Outer.Inner', 'Outer']`). A companion member is
|
|
1127
|
+
* keyed `<EnclosingClass>.<NAME>` and is bound unqualified exactly within that
|
|
1128
|
+
* class body — including its nested types, which is why the whole chain is
|
|
1129
|
+
* walked and not just the innermost link. An `object`'s own members are in scope
|
|
1130
|
+
* inside its body under the same `<Owner>.<NAME>` key, so the same walk covers
|
|
1131
|
+
* both.
|
|
1132
|
+
*
|
|
1133
|
+
* Innermost-first, and BEFORE the file-level maps the fold consults next, is
|
|
1134
|
+
* Kotlin's own order: a companion member shadows a same-named top-level
|
|
1135
|
+
* declaration and a same-named import throughout its class. Outside that class
|
|
1136
|
+
* the bare name never means the companion at all, which is precisely what an
|
|
1137
|
+
* empty chain expresses.
|
|
1138
|
+
*/
|
|
1139
|
+
function qualifyKotlinRefInEnclosingTypes(fileKey, name, repo, enclosingTypes) {
|
|
1140
|
+
// A dotted reference carries AN owner, not necessarily its OWN full one, so it
|
|
1141
|
+
// is resolved against the enclosing scopes exactly like a bare name. Kotlin
|
|
1142
|
+
// binds `Inner.Q` inside `object Outer` to `Outer.Inner.Q`, and returning it
|
|
1143
|
+
// unchanged looked for a key nothing declares. Worse, when the partial owner
|
|
1144
|
+
// also names a top-level declaration the unchanged form MATCHES it: with a
|
|
1145
|
+
// top-level `object ApiPaths` beside a nested one, `@GetMapping(ApiPaths.ORDERS)`
|
|
1146
|
+
// inside the class holding the nested object resolved to the top-level value —
|
|
1147
|
+
// a path the application does not serve, where the compiler binds the nested
|
|
1148
|
+
// one. The scopes are already qualified (`kotlinEnclosingTypeNames`), so
|
|
1149
|
+
// prefixing them onto whatever the reference spells is the whole rule.
|
|
1150
|
+
const mc = repo.get(fileKey);
|
|
1151
|
+
if (!mc)
|
|
1152
|
+
return name;
|
|
1153
|
+
const unfoldableDeclarations = unfoldableDeclarationsOf(mc);
|
|
1154
|
+
for (const type of enclosingTypes) {
|
|
1155
|
+
const key = `${type}.${name}`;
|
|
1156
|
+
if (mc.literals.has(key) || mc.exprs.has(key) || unfoldableDeclarations.has(key)) {
|
|
1157
|
+
return key;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return name;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Fold an inline operand list (e.g. `ApiPaths.BASE + "/orders"`) against
|
|
1164
|
+
* `fileKey`, or null when any piece is unresolvable (skip floor).
|
|
1165
|
+
*
|
|
1166
|
+
* `enclosingTypes` is the chain of type declarations the REFERENCE sits inside
|
|
1167
|
+
* (innermost first), and it is applied to the entry operands only — everything
|
|
1168
|
+
* deeper is either already qualified by
|
|
1169
|
+
* {@link extractKotlinModuleConstants} against its own declaring scope, or lives
|
|
1170
|
+
* in another file where this chain means nothing. Passing it empty answers
|
|
1171
|
+
* "what does this name mean at file level", which is the right question for a
|
|
1172
|
+
* reference outside any type and the only one a caller without position
|
|
1173
|
+
* information can honestly ask.
|
|
1174
|
+
*
|
|
1175
|
+
* An empty result is a SUCCESS, not a skip. `const val ROOT = ""` folds to `""`,
|
|
1176
|
+
* which `joinPath` then resolves against the class-level prefix exactly as it
|
|
1177
|
+
* resolves the literal `@GetMapping("")` — both mean "the prefix itself", the
|
|
1178
|
+
* Spring idiom for a collection root. Collapsing it into `null` would make a
|
|
1179
|
+
* resolved-empty path indistinguishable from an unresolvable one — the skip
|
|
1180
|
+
* floor is reserved for "could not fold", and nothing else in the resolver
|
|
1181
|
+
* conflates the two: {@link resolveKotlinConstant} returns `''` for an empty
|
|
1182
|
+
* constant, and `resolveOperands` in the shared core returns its fold
|
|
1183
|
+
* unfiltered. Matches `foldJavaOperands`, so the two JVM bindings do not
|
|
1184
|
+
* diverge on the same input.
|
|
1185
|
+
*/
|
|
1186
|
+
export function foldKotlinOperands(fileKey, operands, repo, enclosingTypes = [], index) {
|
|
1187
|
+
// Allocation gate only: skip the map when there is nothing to qualify against
|
|
1188
|
+
// or no reference to qualify. It must not restate the rule — a dotted operand
|
|
1189
|
+
// is qualified too, so testing for a bare one here decided the result instead
|
|
1190
|
+
// of merely avoiding an allocation, and did so per-OPERAND-LIST: the same
|
|
1191
|
+
// `Inner.Q` folded or not depending on whether a SIBLING operand happened to
|
|
1192
|
+
// be bare.
|
|
1193
|
+
const needsQualify = enclosingTypes.length > 0 && operands.some((op) => op.kind === 'ref');
|
|
1194
|
+
const scoped = needsQualify
|
|
1195
|
+
? operands.map((op) => op.kind === 'ref'
|
|
1196
|
+
? {
|
|
1197
|
+
kind: 'ref',
|
|
1198
|
+
name: qualifyKotlinRefInEnclosingTypes(fileKey, op.name, repo, enclosingTypes),
|
|
1199
|
+
}
|
|
1200
|
+
: op)
|
|
1201
|
+
: operands;
|
|
1202
|
+
return foldOperands(fileKey, scoped, newFoldState(repo, index), 0);
|
|
1203
|
+
}
|