@telorun/ide-support 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cel-chain.d.ts +36 -0
  2. package/dist/cel-chain.d.ts.map +1 -0
  3. package/dist/cel-chain.js +77 -0
  4. package/dist/completions/detect-context.d.ts +5 -5
  5. package/dist/completions/detect-context.d.ts.map +1 -1
  6. package/dist/completions/detect-context.js +72 -5
  7. package/dist/completions/prop-keys.d.ts +1 -1
  8. package/dist/completions/prop-keys.d.ts.map +1 -1
  9. package/dist/completions/prop-keys.js +18 -1
  10. package/dist/definition/resolve-cel-target.d.ts.map +1 -1
  11. package/dist/definition/resolve-cel-target.js +1 -60
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/rename/build-rename.d.ts +39 -0
  16. package/dist/rename/build-rename.d.ts.map +1 -0
  17. package/dist/rename/build-rename.js +407 -0
  18. package/dist/rename/find-sites.d.ts +49 -0
  19. package/dist/rename/find-sites.d.ts.map +1 -0
  20. package/dist/rename/find-sites.js +202 -0
  21. package/dist/rename/index.d.ts +3 -0
  22. package/dist/rename/index.d.ts.map +1 -0
  23. package/dist/rename/index.js +1 -0
  24. package/dist/rename/types.d.ts +55 -0
  25. package/dist/rename/types.d.ts.map +1 -0
  26. package/dist/rename/types.js +1 -0
  27. package/package.json +2 -2
  28. package/src/cel-chain.ts +86 -0
  29. package/src/completions/detect-context.ts +73 -6
  30. package/src/completions/prop-keys.ts +23 -2
  31. package/src/definition/resolve-cel-target.ts +1 -68
  32. package/src/index.ts +1 -0
  33. package/src/rename/build-rename.ts +513 -0
  34. package/src/rename/find-sites.ts +215 -0
  35. package/src/rename/index.ts +9 -0
  36. package/src/rename/types.ts +51 -0
@@ -0,0 +1,513 @@
1
+ import {
2
+ CelParseError,
3
+ buildLineOffsets,
4
+ checkName,
5
+ offsetToPosition,
6
+ parseToAst,
7
+ type AstDocument,
8
+ type AstScalar,
9
+ type LoadedFile,
10
+ type LoadedGraph,
11
+ type LoadedModule,
12
+ type Range,
13
+ } from "@telorun/analyzer";
14
+
15
+ import { chainAt } from "../cel-chain.js";
16
+ import { resolveNodeAtPosition, scalarString } from "../completions/resolve-node.js";
17
+ import { moduleDoc, moduleForFile, moduleFiles } from "../definition/manifest-navigation.js";
18
+ import {
19
+ declarationSites,
20
+ resourceDeclarations,
21
+ resourceSites,
22
+ stepDeclarations,
23
+ stepSites,
24
+ type NameSite,
25
+ } from "./find-sites.js";
26
+ import type {
27
+ RenameEdit,
28
+ RenameFileEdits,
29
+ RenamePreparation,
30
+ RenameResult,
31
+ RenameSymbol,
32
+ } from "./types.js";
33
+
34
+ /**
35
+ * Rename a name and every reference to it.
36
+ *
37
+ * **This is a refactor, not a fix**, and the distinction is the reason it lives
38
+ * here rather than behind a `DiagnosticFix`. A fix is a whole-value replacement
39
+ * for ONE node, verified by the diagnostic that produced it; a rename is only
40
+ * correct when every reference moves with it, which means the operation's unit
41
+ * is the reference graph, not the node. Renaming `metadata.name` alone leaves
42
+ * every `!ref`, `resources.<name>` and `steps.<name>.result` pointing at a name
43
+ * that no longer exists — so a rename offered as a quick fix would break the
44
+ * file it claimed to repair.
45
+ *
46
+ * **Three renameable surfaces, and every other one is an explicit refusal.**
47
+ * What they have in common is that their reference set is *enumerable from this
48
+ * workspace*: a resource instance, a step, and a `variables:` / `secrets:` /
49
+ * `ports:` key are all module-local. A kind name, a module name and an import
50
+ * alias are not supported yet — their references reach schema annotations
51
+ * (`x-telo-ref`, `extends`, `exports.kinds`) as alias-qualified halves of a
52
+ * larger value, which is a materially bigger surface than a bare identifier and
53
+ * wants its own pass.
54
+ *
55
+ * **A name in `exports.resources` is refused outright**, and that is the load-
56
+ * bearing refusal. Such a name is the library's public ABI: a consumer writes
57
+ * `!ref Alias.name` and reads `resources.Alias.name`, in files this workspace
58
+ * may not contain and, for a published consumer, cannot. Renaming it is a
59
+ * breaking change to be *versioned*, not an edit to be applied — and a rename
60
+ * box that silently ships one would be the worst available framing.
61
+ *
62
+ * **Ambiguity is refused rather than guessed.** A name declared twice in reach
63
+ * (a `with:`-scoped resource shadowing a module-level one, two steps in one
64
+ * resource sharing a spelling) has references that resolve to different
65
+ * declarations, and no edit set is right for both.
66
+ */
67
+ export function prepareRename(
68
+ text: string,
69
+ line: number,
70
+ character: number,
71
+ graph: LoadedGraph,
72
+ currentFilePath: string,
73
+ docs?: AstDocument[],
74
+ ): RenamePreparation {
75
+ const astDocs = docs ?? parseToAst(text);
76
+ const resolved = resolveNodeAtPosition(text, astDocs, line, character);
77
+ if (!resolved) return { ok: false, reason: "Nothing renameable here." };
78
+
79
+ const mod = moduleForFile(graph, currentFilePath);
80
+ if (!mod) {
81
+ return { ok: false, reason: "This file is not part of a loaded Telo module." };
82
+ }
83
+
84
+ const lineOffsets = buildLineOffsets(text);
85
+ const toRange = (span: [number, number]): Range => ({
86
+ start: offsetToPosition(span[0], lineOffsets),
87
+ end: offsetToPosition(span[1], lineOffsets),
88
+ });
89
+
90
+ const symbol = symbolAt(resolved, astDocs, toRange);
91
+ if (!symbol) return { ok: false, reason: "Nothing renameable here." };
92
+ if ("reason" in symbol) return { ok: false, reason: symbol.reason };
93
+
94
+ const refusal = refuse(symbol, mod, currentFilePath, text, astDocs);
95
+ return refusal ? { ok: false, reason: refusal } : { ok: true, symbol };
96
+ }
97
+
98
+ /** Prepare, validate the new name, then collect every edit. */
99
+ export function buildRename(
100
+ text: string,
101
+ line: number,
102
+ character: number,
103
+ newName: string,
104
+ graph: LoadedGraph,
105
+ currentFilePath: string,
106
+ docs?: AstDocument[],
107
+ ): RenameResult {
108
+ const prepared = prepareRename(text, line, character, graph, currentFilePath, docs);
109
+ if (!prepared.ok) return prepared;
110
+ const { symbol } = prepared;
111
+
112
+ if (newName === symbol.name) return { ok: true, symbol, files: [] };
113
+
114
+ // Every renameable surface is value-level, so the new name is checked against
115
+ // that half of the convention — the same rule `telo check` enforces. Renaming
116
+ // *into* a name the analyzer would reject is a mistake worth catching in the
117
+ // rename box rather than as a squiggle afterwards.
118
+ const violation = checkName(newName, "value", surfaceLabel(symbol));
119
+ if (violation) return { ok: false, reason: violation.message };
120
+
121
+ const mod = moduleForFile(graph, currentFilePath)!;
122
+ const files = filesForRename(symbol, mod, currentFilePath, text, docs);
123
+
124
+ const out: RenameFileEdits[] = [];
125
+ for (const file of files) {
126
+ const edits = editsIn(file, symbol, newName);
127
+ if (edits.length > 0) out.push({ uri: file.uri, edits });
128
+ }
129
+ if (out.length === 0) {
130
+ return { ok: false, reason: `Found nothing to rename for '${symbol.name}'.` };
131
+ }
132
+ return { ok: true, symbol, files: out };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // What is under the cursor
137
+ // ---------------------------------------------------------------------------
138
+
139
+ type SymbolOrRefusal = RenameSymbol | { reason: string };
140
+
141
+ const DECLARATION_BLOCKS = new Set(["variables", "secrets", "ports"]);
142
+ const MODULE_DOC_KINDS = new Set(["Telo.Application", "Telo.Library"]);
143
+ const KIND_DOC_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
144
+ const SELF_PREFIX = "Self.";
145
+
146
+ /** Dispatch on what the cursor sits IN rather than on the field it is under —
147
+ * the posture `buildDefinition` takes, so the two features agree about what a
148
+ * given position means. */
149
+ function symbolAt(
150
+ resolved: ReturnType<typeof resolveNodeAtPosition> & object,
151
+ astDocs: AstDocument[],
152
+ toRange: (span: [number, number]) => Range,
153
+ ): SymbolOrRefusal | undefined {
154
+ if (resolved.cel) return celSymbol(resolved.cel, toRange);
155
+
156
+ const path = resolved.path;
157
+ const node = resolved.node;
158
+
159
+ // A key inside `variables:` / `secrets:` / `ports:` on the module doc.
160
+ if (
161
+ resolved.slot === "key" &&
162
+ node?.kind === "scalar" &&
163
+ path.length === 1 &&
164
+ DECLARATION_BLOCKS.has(path[0]) &&
165
+ MODULE_DOC_KINDS.has(resolved.docKind ?? "")
166
+ ) {
167
+ const name = scalarString(node);
168
+ if (!name) return undefined;
169
+ return {
170
+ kind: "declaration",
171
+ name,
172
+ range: toRange(node.range),
173
+ block: path[0] as "variables" | "secrets" | "ports",
174
+ };
175
+ }
176
+
177
+ if (resolved.slot !== "value" || node?.kind !== "scalar") return undefined;
178
+ const scalar = node as AstScalar;
179
+
180
+ // A `!ref` target. `<Alias>.<name>` crosses an import boundary, where the
181
+ // declaration is another module's and the edit set is not this workspace's.
182
+ if (scalar.tag === "!ref") {
183
+ const raw = refText(scalar);
184
+ if (!raw) return undefined;
185
+ if (raw.startsWith(SELF_PREFIX)) {
186
+ const name = raw.slice(SELF_PREFIX.length);
187
+ const start = scalar.range[0] + SELF_PREFIX.length;
188
+ return { kind: "resource", name, range: toRange([start, scalar.range[1]]) };
189
+ }
190
+ if (raw.includes(".")) {
191
+ return {
192
+ reason:
193
+ `'${raw}' names an instance exported by an imported module. Rename it where it is ` +
194
+ `declared — and only if it is not part of that module's exports.`,
195
+ };
196
+ }
197
+ return { kind: "resource", name: raw, range: toRange(scalar.range) };
198
+ }
199
+
200
+ const name = scalarString(scalar);
201
+ if (!name) return undefined;
202
+
203
+ // `metadata.name` — a declaration site. Which surface it is depends on the
204
+ // document's kind, and two of the three are type-level.
205
+ if (path.length === 2 && path[0] === "metadata" && path[1] === "name") {
206
+ const docKind = resolved.docKind ?? "";
207
+ if (MODULE_DOC_KINDS.has(docKind)) {
208
+ return {
209
+ reason:
210
+ "Renaming a module is not supported yet — its name is the canonical kind prefix, " +
211
+ "so every consumer's `kind:` and `extends:` values resolve through it.",
212
+ };
213
+ }
214
+ if (KIND_DOC_KINDS.has(docKind)) {
215
+ return {
216
+ reason:
217
+ "Renaming a kind is not supported yet — its references are alias-qualified halves " +
218
+ "of `kind:`, `extends:`, `x-telo-ref` and `exports.kinds` values.",
219
+ };
220
+ }
221
+ if (docKind === "Telo.Import") {
222
+ return {
223
+ reason:
224
+ "Renaming an import alias is not supported yet — the alias is the prefix of every " +
225
+ "`kind:`, `extends:` and `x-telo-ref` value that resolves through it.",
226
+ };
227
+ }
228
+ return { kind: "resource", name, range: toRange(scalar.range) };
229
+ }
230
+
231
+ // A step's `name:` — the last path segment, with no enclosing `metadata`.
232
+ if (path.length >= 1 && path[path.length - 1] === "name") {
233
+ return { kind: "step", name, range: toRange(scalar.range) };
234
+ }
235
+
236
+ // A bare scalar under `exports.resources`, which is where the ABI refusal is
237
+ // most likely to be attempted from.
238
+ if (path.length === 2 && path[0] === "exports" && path[1] === "resources") {
239
+ return { kind: "resource", name, range: toRange(scalar.range) };
240
+ }
241
+
242
+ return undefined;
243
+ }
244
+
245
+ /** A CEL chain root that names a renameable scope, with the cursor on its
246
+ * member. Anything deeper is a field of a resolved value, not a declaration. */
247
+ function celSymbol(
248
+ cel: { segment: { ast(): unknown; source: string }; offset: number },
249
+ toRange: (span: [number, number]) => Range,
250
+ ): SymbolOrRefusal | undefined {
251
+ let ast;
252
+ try {
253
+ ast = cel.segment.ast() as Parameters<typeof chainAt>[0];
254
+ } catch (error) {
255
+ if (!(error instanceof CelParseError)) throw error;
256
+ return undefined;
257
+ }
258
+ const hit = chainAt(ast, cel.offset);
259
+ if (!hit || hit.index !== 1) return undefined;
260
+ const root = hit.parts[0].name;
261
+ const part = hit.parts[1];
262
+
263
+ if (root === "resources") {
264
+ return { kind: "resource", name: part.name, range: toRange(part.range) };
265
+ }
266
+ if (root === "steps") {
267
+ return { kind: "step", name: part.name, range: toRange(part.range) };
268
+ }
269
+ if (DECLARATION_BLOCKS.has(root)) {
270
+ return {
271
+ kind: "declaration",
272
+ name: part.name,
273
+ range: toRange(part.range),
274
+ block: root as "variables" | "secrets" | "ports",
275
+ };
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ function refText(scalar: AstScalar): string | undefined {
281
+ const value = scalar.value as { source?: unknown } | string | undefined;
282
+ if (typeof value === "string") return value;
283
+ if (value && typeof value === "object" && typeof value.source === "string") return value.source;
284
+ return undefined;
285
+ }
286
+
287
+ function surfaceLabel(symbol: RenameSymbol): string {
288
+ if (symbol.kind === "resource") return "resource name";
289
+ if (symbol.kind === "step") return "step name";
290
+ return `${symbol.block === "ports" ? "port" : symbol.block === "secrets" ? "secret" : "variable"} name`;
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Refusals
295
+ // ---------------------------------------------------------------------------
296
+
297
+ function refuse(
298
+ symbol: RenameSymbol,
299
+ mod: LoadedModule,
300
+ currentFilePath: string,
301
+ text: string,
302
+ astDocs: AstDocument[],
303
+ ): string | undefined {
304
+ const doc = moduleDoc(mod) as
305
+ | { kind?: string; exports?: { resources?: unknown } }
306
+ | undefined;
307
+
308
+ if (symbol.kind === "resource") {
309
+ const exported = Array.isArray(doc?.exports?.resources)
310
+ ? (doc!.exports!.resources as unknown[]).filter((e): e is string => typeof e === "string")
311
+ : [];
312
+ // An entry is either `<name>` (local) or `<Alias>.<name>` (a re-export,
313
+ // whose declaration is not this module's anyway).
314
+ if (exported.some((e) => e === symbol.name)) {
315
+ return (
316
+ `'${symbol.name}' is listed in this module's 'exports.resources', so it is part of its ` +
317
+ `public surface — consumers reference it as '!ref <Alias>.${symbol.name}' in files this ` +
318
+ `workspace may not contain. Renaming it is a breaking change; version it instead.`
319
+ );
320
+ }
321
+
322
+ const declarations = countResourceDeclarations(mod, symbol.name, currentFilePath, text, astDocs);
323
+ if (declarations > 1) {
324
+ return (
325
+ `'${symbol.name}' is declared ${declarations} times in this module — a scoped ('with:') ` +
326
+ `declaration shadows a module-level one, so references resolve to different resources ` +
327
+ `depending on where they sit. Disambiguate them first.`
328
+ );
329
+ }
330
+ if (declarations === 0) {
331
+ return `Could not find where '${symbol.name}' is declared in this module.`;
332
+ }
333
+ return undefined;
334
+ }
335
+
336
+ if (symbol.kind === "step") {
337
+ // Document-scoped, and the cursor is in this file, so the live docs are the
338
+ // only ones that can hold the declaration.
339
+ const target = astDocs.find((d) => stepDeclarations(d, symbol.name).length > 0);
340
+ if (!target) return `Could not find a step named '${symbol.name}' in this document.`;
341
+ const count = stepDeclarations(target, symbol.name).length;
342
+ if (count > 1) {
343
+ return (
344
+ `'${symbol.name}' names ${count} steps in this document, so 'steps.${symbol.name}.result' ` +
345
+ `is ambiguous. Disambiguate them first.`
346
+ );
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ // A Library's declared config is its contract: an importer passes values
352
+ // keyed by these names, so renaming one breaks every consumer exactly as
353
+ // renaming an exported instance does.
354
+ if (doc?.kind === "Telo.Library") {
355
+ return (
356
+ `'${symbol.block}.${symbol.name}' is part of this library's contract — importers pass ` +
357
+ `values keyed by that name. Renaming it is a breaking change; version it instead.`
358
+ );
359
+ }
360
+ return undefined;
361
+ }
362
+
363
+ /** Declarations of a resource name across the module, counting nested (scoped)
364
+ * ones. The live buffer stands in for the current file's snapshot. */
365
+ function countResourceDeclarations(
366
+ mod: LoadedModule,
367
+ name: string,
368
+ currentFilePath: string,
369
+ text: string,
370
+ astDocs: AstDocument[],
371
+ ): number {
372
+ let count = 0;
373
+ for (const file of moduleFiles(mod)) {
374
+ const docs = file.source === currentFilePath ? astDocs : file.astDocuments;
375
+ for (const doc of docs) count += resourceDeclarations(doc, name).length;
376
+ }
377
+ return count;
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Edit collection
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /** A file to rewrite, with the text its offsets are measured against. */
385
+ interface RenameFile {
386
+ uri: string;
387
+ text: string;
388
+ docs: AstDocument[];
389
+ }
390
+
391
+ /**
392
+ * Which files a rename may touch.
393
+ *
394
+ * A resource or a config declaration is module-scoped, so every file in the
395
+ * module's scope is in range. A **step is document-scoped**: `steps.<name>.result`
396
+ * is readable only inside the resource whose body declares the step, and a
397
+ * resource is one YAML document — so the edit set is that document alone, which
398
+ * is also what makes two same-named steps in one document the ambiguity to
399
+ * refuse rather than a cross-file hazard.
400
+ *
401
+ * The live buffer always stands in for the current file. The graph is a snapshot
402
+ * taken at the last analysis, so applying edits computed against it would write
403
+ * stale offsets into a file the author has since edited.
404
+ */
405
+ function filesForRename(
406
+ symbol: RenameSymbol,
407
+ mod: LoadedModule,
408
+ currentFilePath: string,
409
+ text: string,
410
+ docs: AstDocument[] | undefined,
411
+ ): RenameFile[] {
412
+ const live = docs ?? parseToAst(text);
413
+ const asRenameFile = (file: LoadedFile): RenameFile =>
414
+ file.source === currentFilePath
415
+ ? { uri: file.source, text, docs: live }
416
+ : { uri: file.source, text: file.text, docs: file.astDocuments };
417
+
418
+ const all = moduleFiles(mod).map(asRenameFile);
419
+ if (symbol.kind !== "step") return all;
420
+
421
+ // Narrow to the one document declaring the step.
422
+ for (const file of all) {
423
+ const index = file.docs.findIndex((d) => stepDeclarations(d, symbol.name).length > 0);
424
+ if (index >= 0) return [{ ...file, docs: [file.docs[index]] }];
425
+ }
426
+ return [];
427
+ }
428
+
429
+ function editsIn(file: RenameFile, symbol: RenameSymbol, newName: string): RenameEdit[] {
430
+ const lineOffsets = buildLineOffsets(file.text);
431
+ const spans: Array<[number, number]> = [];
432
+
433
+ for (const doc of file.docs) {
434
+ if (symbol.kind === "resource") {
435
+ for (const span of resourceDeclarations(doc, symbol.name)) spans.push(span);
436
+ for (const site of resourceSites(doc, symbol.name)) spans.push(site.range);
437
+ for (const span of exportEntrySpans(doc, symbol.name)) spans.push(span);
438
+ } else if (symbol.kind === "step") {
439
+ for (const span of stepDeclarations(doc, symbol.name)) spans.push(span);
440
+ for (const site of stepSites(doc, symbol.name)) spans.push(site.range);
441
+ } else {
442
+ for (const span of declarationKeySpans(doc, symbol.block!, symbol.name)) spans.push(span);
443
+ for (const site of declarationSites(doc, symbol.block!, symbol.name)) spans.push(site.range);
444
+ }
445
+ }
446
+
447
+ // Sorted and de-duplicated: a host applies a set of edits, and two edits over
448
+ // one span — which a name reachable by two walks would produce — is an
449
+ // overlapping-edit error in every LSP client.
450
+ return dedupe(spans).map((span) => ({
451
+ range: {
452
+ start: offsetToPosition(span[0], lineOffsets),
453
+ end: offsetToPosition(span[1], lineOffsets),
454
+ },
455
+ newText: newName,
456
+ }));
457
+ }
458
+
459
+ function dedupe(spans: Array<[number, number]>): Array<[number, number]> {
460
+ const seen = new Set<string>();
461
+ const out: Array<[number, number]> = [];
462
+ for (const span of spans.sort((a, b) => a[0] - b[0] || a[1] - b[1])) {
463
+ const key = `${span[0]}:${span[1]}`;
464
+ if (seen.has(key)) continue;
465
+ seen.add(key);
466
+ out.push(span);
467
+ }
468
+ return out;
469
+ }
470
+
471
+ /** Spans of `exports.resources` entries naming the resource. Collected even
472
+ * though an exported name is refused, because the refusal is decided from the
473
+ * module doc's JSON while these come from the AST: a name reached here that the
474
+ * refusal did not catch (a re-export spelled `Self.<name>`) still has to move
475
+ * with its declaration rather than being left dangling. */
476
+ function exportEntrySpans(doc: AstDocument, name: string): Array<[number, number]> {
477
+ const out: Array<[number, number]> = [];
478
+ if (doc.root?.kind !== "map") return out;
479
+ for (const pair of doc.root.entries) {
480
+ if (scalarString(pair.key) !== "exports" || pair.value?.kind !== "map") continue;
481
+ for (const inner of pair.value.entries) {
482
+ if (scalarString(inner.key) !== "resources" || inner.value?.kind !== "seq") continue;
483
+ for (const item of inner.value.items) {
484
+ if (item.kind !== "scalar") continue;
485
+ const value = scalarString(item);
486
+ if (value === name) out.push(item.range);
487
+ else if (value === `${SELF_PREFIX}${name}`) {
488
+ out.push([item.range[0] + SELF_PREFIX.length, item.range[1]]);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ return out;
494
+ }
495
+
496
+ /** The `variables:` / `secrets:` / `ports:` key itself. */
497
+ function declarationKeySpans(
498
+ doc: AstDocument,
499
+ block: string,
500
+ name: string,
501
+ ): Array<[number, number]> {
502
+ const out: Array<[number, number]> = [];
503
+ if (doc.root?.kind !== "map") return out;
504
+ for (const pair of doc.root.entries) {
505
+ if (scalarString(pair.key) !== block || pair.value?.kind !== "map") continue;
506
+ for (const inner of pair.value.entries) {
507
+ if (inner.key.kind === "scalar" && scalarString(inner.key) === name) {
508
+ out.push(inner.key.range);
509
+ }
510
+ }
511
+ }
512
+ return out;
513
+ }