@telorun/ide-support 0.6.0 → 0.7.0

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 (53) hide show
  1. package/dist/completions/build.d.ts +2 -2
  2. package/dist/completions/build.d.ts.map +1 -1
  3. package/dist/completions/build.js +39 -64
  4. package/dist/completions/detect-context.d.ts +16 -43
  5. package/dist/completions/detect-context.d.ts.map +1 -1
  6. package/dist/completions/detect-context.js +63 -262
  7. package/dist/completions/import-source.d.ts +5 -5
  8. package/dist/completions/import-source.d.ts.map +1 -1
  9. package/dist/completions/import-source.js +15 -15
  10. package/dist/completions/resolve-node.d.ts +50 -0
  11. package/dist/completions/resolve-node.d.ts.map +1 -0
  12. package/dist/completions/resolve-node.js +269 -0
  13. package/dist/completions/valid-capabilities.d.ts +3 -0
  14. package/dist/completions/valid-capabilities.d.ts.map +1 -1
  15. package/dist/completions/valid-capabilities.js +10 -0
  16. package/dist/definition/build-definition.d.ts +13 -0
  17. package/dist/definition/build-definition.d.ts.map +1 -0
  18. package/dist/definition/build-definition.js +98 -0
  19. package/dist/definition/index.d.ts +2 -0
  20. package/dist/definition/index.d.ts.map +1 -0
  21. package/dist/definition/index.js +1 -0
  22. package/dist/hover/build-hover.d.ts +4 -0
  23. package/dist/hover/build-hover.d.ts.map +1 -0
  24. package/dist/hover/build-hover.js +125 -0
  25. package/dist/hover/index.d.ts +2 -0
  26. package/dist/hover/index.d.ts.map +1 -0
  27. package/dist/hover/index.js +1 -0
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/semantic-tokens/build-semantic-tokens.d.ts +12 -0
  32. package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -0
  33. package/dist/semantic-tokens/build-semantic-tokens.js +55 -0
  34. package/dist/semantic-tokens/index.d.ts +2 -0
  35. package/dist/semantic-tokens/index.d.ts.map +1 -0
  36. package/dist/semantic-tokens/index.js +1 -0
  37. package/dist/types.d.ts +45 -6
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js +3 -0
  40. package/package.json +2 -2
  41. package/src/completions/build.ts +40 -72
  42. package/src/completions/detect-context.ts +74 -291
  43. package/src/completions/import-source.ts +16 -16
  44. package/src/completions/resolve-node.ts +405 -0
  45. package/src/completions/valid-capabilities.ts +11 -0
  46. package/src/definition/build-definition.ts +123 -0
  47. package/src/definition/index.ts +1 -0
  48. package/src/hover/build-hover.ts +149 -0
  49. package/src/hover/index.ts +1 -0
  50. package/src/index.ts +3 -0
  51. package/src/semantic-tokens/build-semantic-tokens.ts +64 -0
  52. package/src/semantic-tokens/index.ts +1 -0
  53. package/src/types.ts +57 -6
@@ -0,0 +1,405 @@
1
+ import {
2
+ buildLineOffsets,
3
+ offsetToPosition,
4
+ type AstDocument,
5
+ type AstMap,
6
+ type AstNode,
7
+ type AstScalar,
8
+ type CelSegment,
9
+ type Position,
10
+ } from "@telorun/analyzer";
11
+
12
+ /** The cursor's resolved position against the read-only AST. `locate` produces
13
+ * this; `detect-context` maps it onto a `CompletionCtx`. Structure comes from
14
+ * the AST; the cursor *column* is used only to resolve empty-space (blank /
15
+ * trailing-indent) key positions, where indentation is the sole signal for
16
+ * "which container am I typing into". */
17
+ export interface ResolvedCursor {
18
+ docIndex: number;
19
+ /** Top-level `kind:` value of the cursor's document, when present. */
20
+ docKind?: string;
21
+ slot: "key" | "value";
22
+ /** Value slot: ancestor keys + the field key (last element is the field).
23
+ * Key slot: the container map's ancestor key chain. */
24
+ path: string[];
25
+ node?: AstNode;
26
+ container?: AstMap;
27
+ replaceRange?: { start: Position; end: Position };
28
+ /** Value slot: text from the value start up to the cursor. */
29
+ prefix?: string;
30
+ /** Value slot: true when whitespace separates the key's colon from the value
31
+ * (distinguishes `Console: ` — a value — from `Tiny:` — a bare header). */
32
+ spaceAfterColon?: boolean;
33
+ /** Value slot: the value of a sibling `kind:` in the same map (object-form
34
+ * ref name completion). */
35
+ siblingKind?: string;
36
+ /** Key slot: keys already present in the container (the key under the cursor
37
+ * excluded so it still suggests itself). */
38
+ existingKeys?: Set<string>;
39
+ /** Key slot: the kind of the nearest enclosing inline resource (or the root
40
+ * resource), whose schema the prop keys are completed against. Falls back to
41
+ * the document kind at the root. */
42
+ resourceKind?: string;
43
+ /** Key slot: number of `path` segments that reach `resourceKind`'s map, so
44
+ * the schema-relative path is `path.slice(resourceDepth)`. */
45
+ resourceDepth?: number;
46
+ /** Set when the cursor sits inside a CEL body (closed or open). Populated for
47
+ * a future CEL-completion feature; this refactor does not consume it. */
48
+ cel?: { segment: CelSegment; offset: number };
49
+ }
50
+
51
+ function within(range: [number, number], offset: number): boolean {
52
+ return offset >= range[0] && offset <= range[1];
53
+ }
54
+
55
+ export function scalarString(node: AstNode | undefined): string | undefined {
56
+ if (node?.kind === "scalar" && typeof node.value === "string") return node.value;
57
+ return undefined;
58
+ }
59
+
60
+ /** The `kind:` value of a document's root map, if any. */
61
+ function docKindOf(doc: AstDocument): string | undefined {
62
+ if (doc.root?.kind !== "map") return undefined;
63
+ for (const pair of doc.root.entries) {
64
+ if (scalarString(pair.key) === "kind") return scalarString(pair.value);
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ /** Value of a sibling `kind:` entry in `map`, for object-form ref detection. */
70
+ function siblingKindOf(map: AstMap): string | undefined {
71
+ for (const pair of map.entries) {
72
+ if (scalarString(pair.key) === "kind") return scalarString(pair.value);
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /** The map's `kind:` value when it names a resource kind (`Alias.Kind`), i.e.
78
+ * the map is an inline resource. A prop-key position inside such a map is
79
+ * completed against *this* kind's schema, not the outer ref slot's. */
80
+ function resourceKindOf(map: AstMap): string | undefined {
81
+ const kind = siblingKindOf(map);
82
+ return kind && /^\w+\.\w+/.test(kind) ? kind : undefined;
83
+ }
84
+
85
+ /** The kind + path-depth of the nearest enclosing inline resource (or the root
86
+ * resource). `depth` is the number of `path` segments consumed to reach that
87
+ * map, so a prop-key `yamlPath` relative to it is `path.slice(depth)`. */
88
+ interface ResourceScope {
89
+ kind?: string;
90
+ depth: number;
91
+ }
92
+
93
+ function enter(scope: ResourceScope, map: AstMap, ancestorsLen: number): ResourceScope {
94
+ const kind = resourceKindOf(map);
95
+ return kind ? { kind, depth: ancestorsLen } : scope;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Containment descent — used for the cursor sitting ON a real node.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ type Descent =
103
+ | {
104
+ type: "key";
105
+ container: AstMap;
106
+ path: string[];
107
+ keyNode: AstScalar;
108
+ keyName?: string;
109
+ scope: ResourceScope;
110
+ }
111
+ | {
112
+ type: "value";
113
+ /** The enclosing map, or `undefined` for a bare scalar sequence item
114
+ * (which has no keyed siblings). */
115
+ container: AstMap | undefined;
116
+ path: string[];
117
+ keyName?: string;
118
+ keyEnd: number;
119
+ valueNode: AstNode;
120
+ }
121
+ | { type: "empty" };
122
+
123
+ function descend(
124
+ node: AstNode,
125
+ ancestors: string[],
126
+ offset: number,
127
+ scope: ResourceScope,
128
+ ): Descent | undefined {
129
+ if (node.kind === "map") {
130
+ const mapScope = enter(scope, node, ancestors.length);
131
+ for (const pair of node.entries) {
132
+ const keyName = scalarString(pair.key);
133
+ if (within(pair.key.range, offset)) {
134
+ return {
135
+ type: "key",
136
+ container: node,
137
+ path: ancestors,
138
+ keyNode: pair.key as AstScalar,
139
+ keyName,
140
+ scope: mapScope,
141
+ };
142
+ }
143
+ if (pair.value && within(pair.value.range, offset)) {
144
+ const childAncestors = keyName != null ? [...ancestors, keyName] : ancestors;
145
+ if (pair.value.kind === "map" || pair.value.kind === "seq") {
146
+ return descend(pair.value, childAncestors, offset, mapScope) ?? { type: "empty" };
147
+ }
148
+ return {
149
+ type: "value",
150
+ container: node,
151
+ path: ancestors,
152
+ keyName,
153
+ keyEnd: pair.key.range[1],
154
+ valueNode: pair.value,
155
+ };
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+ if (node.kind === "seq") {
161
+ // Sequence items are transparent to the key path (mirrors the schema
162
+ // walker, which auto-descends arrays).
163
+ for (const item of node.items) {
164
+ if (within(item.range, offset)) {
165
+ if (item.kind === "map" || item.kind === "seq") {
166
+ return descend(item, ancestors, offset, scope) ?? { type: "empty" };
167
+ }
168
+ // A bare scalar list item (`targets:\n - One`) has no enclosing map of
169
+ // keyed siblings — leave `container` undefined rather than treating the
170
+ // seq as a map.
171
+ return { type: "value", container: undefined, path: ancestors, keyEnd: item.range[0], valueNode: item };
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Column search — used for empty-space (blank / trailing-indent) key positions.
181
+ // ---------------------------------------------------------------------------
182
+
183
+ interface MapScope {
184
+ path: string[];
185
+ childColumn: number;
186
+ keys: Set<string>;
187
+ rangeStart: number;
188
+ scope: ResourceScope;
189
+ }
190
+
191
+ interface PairScope {
192
+ path: string[]; // full key path to this pair
193
+ keyColumn: number;
194
+ keyOffset: number;
195
+ childKeys: Set<string>;
196
+ scope: ResourceScope;
197
+ }
198
+
199
+ function collectScopes(
200
+ node: AstNode,
201
+ ancestors: string[],
202
+ scope: ResourceScope,
203
+ lineOffsets: number[],
204
+ maps: MapScope[],
205
+ pairs: PairScope[],
206
+ ): void {
207
+ if (node.kind === "map") {
208
+ const mapScope = enter(scope, node, ancestors.length);
209
+ const keys = new Set<string>();
210
+ let childColumn = -1;
211
+ for (const pair of node.entries) {
212
+ const keyName = scalarString(pair.key);
213
+ if (keyName != null) keys.add(keyName);
214
+ if (childColumn < 0) childColumn = offsetToPosition(pair.key.range[0], lineOffsets).character;
215
+ }
216
+ if (childColumn >= 0) {
217
+ maps.push({ path: ancestors, childColumn, keys, rangeStart: node.range[0], scope: mapScope });
218
+ }
219
+ for (const pair of node.entries) {
220
+ const keyName = scalarString(pair.key);
221
+ const fullPath = keyName != null ? [...ancestors, keyName] : ancestors;
222
+ const childKeys = new Set<string>();
223
+ if (pair.value?.kind === "map") {
224
+ for (const p of pair.value.entries) {
225
+ const k = scalarString(p.key);
226
+ if (k != null) childKeys.add(k);
227
+ }
228
+ }
229
+ pairs.push({
230
+ path: fullPath,
231
+ keyColumn: offsetToPosition(pair.key.range[0], lineOffsets).character,
232
+ keyOffset: pair.key.range[0],
233
+ childKeys,
234
+ scope: mapScope,
235
+ });
236
+ if (pair.value) collectScopes(pair.value, fullPath, mapScope, lineOffsets, maps, pairs);
237
+ }
238
+ } else if (node.kind === "seq") {
239
+ for (const item of node.items) collectScopes(item, ancestors, scope, lineOffsets, maps, pairs);
240
+ }
241
+ }
242
+
243
+ interface KeyResolution {
244
+ path: string[];
245
+ existingKeys: Set<string>;
246
+ scope: ResourceScope;
247
+ }
248
+
249
+ /** Resolve the container a new key at `cursorColumn` belongs to. Prefers an
250
+ * existing sibling level (a map whose child keys sit at exactly `cursorColumn`);
251
+ * otherwise nests under the nearest-preceding shallower key. */
252
+ function columnSearch(
253
+ root: AstNode,
254
+ cursorColumn: number,
255
+ cursorOffset: number,
256
+ lineOffsets: number[],
257
+ ): KeyResolution {
258
+ const maps: MapScope[] = [];
259
+ const pairs: PairScope[] = [];
260
+ collectScopes(root, [], { depth: 0 }, lineOffsets, maps, pairs);
261
+
262
+ // Sibling level: a map whose children already sit at the cursor's column.
263
+ let sibling: MapScope | undefined;
264
+ for (const m of maps) {
265
+ if (m.childColumn === cursorColumn && m.rangeStart < cursorOffset) {
266
+ if (!sibling || m.rangeStart > sibling.rangeStart) sibling = m;
267
+ }
268
+ }
269
+ if (sibling) return { path: sibling.path, existingKeys: sibling.keys, scope: sibling.scope };
270
+
271
+ // Nest under the nearest-preceding key shallower than the cursor.
272
+ let nest: PairScope | undefined;
273
+ for (const p of pairs) {
274
+ if (p.keyColumn < cursorColumn && p.keyOffset < cursorOffset) {
275
+ if (
276
+ !nest ||
277
+ p.keyColumn > nest.keyColumn ||
278
+ (p.keyColumn === nest.keyColumn && p.keyOffset > nest.keyOffset)
279
+ ) {
280
+ nest = p;
281
+ }
282
+ }
283
+ }
284
+ if (nest) return { path: nest.path, existingKeys: nest.childKeys, scope: nest.scope };
285
+
286
+ return { path: [], existingKeys: new Set(), scope: { depth: 0 } };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function selectDoc(docs: AstDocument[], offset: number): number {
292
+ let best = -1;
293
+ for (let i = 0; i < docs.length; i++) {
294
+ if (docs[i].range[0] <= offset) best = i;
295
+ }
296
+ return best < 0 ? (docs.length > 0 ? 0 : -1) : best;
297
+ }
298
+
299
+ function celAt(node: AstScalar, offset: number): ResolvedCursor["cel"] {
300
+ for (const segment of node.celSegments()) {
301
+ if (offset >= segment.range[0] && offset <= segment.range[1]) return { segment, offset };
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /** Resolve `(line, character)` against the AST (Approach B: AST for structure,
307
+ * cursor column only to place empty-space key positions). */
308
+ export function resolveNodeAtPosition(
309
+ text: string,
310
+ docs: AstDocument[],
311
+ line: number,
312
+ character: number,
313
+ ): ResolvedCursor | undefined {
314
+ if (docs.length === 0) return undefined;
315
+ const lineOffsets = buildLineOffsets(text);
316
+ const offset = (lineOffsets[line] ?? 0) + character;
317
+ const toPos = (o: number): Position => offsetToPosition(o, lineOffsets);
318
+
319
+ const docIndex = selectDoc(docs, offset);
320
+ if (docIndex < 0) return undefined;
321
+ const doc = docs[docIndex];
322
+ const docKind = docKindOf(doc);
323
+
324
+ const found = doc.root ? descend(doc.root, [], offset, { depth: 0 }) : undefined;
325
+
326
+ // Cursor sits on an existing map key → key/prop-key position.
327
+ if (found?.type === "key") {
328
+ const existingKeys = new Set<string>();
329
+ for (const pair of found.container.entries) {
330
+ const k = scalarString(pair.key);
331
+ if (k != null && k !== found.keyName) existingKeys.add(k);
332
+ }
333
+ return {
334
+ docIndex,
335
+ docKind,
336
+ slot: "key",
337
+ path: found.path,
338
+ node: found.keyNode,
339
+ replaceRange: { start: toPos(found.keyNode.range[0]), end: toPos(found.keyNode.range[1]) },
340
+ container: found.container,
341
+ existingKeys,
342
+ resourceKind: found.scope.kind,
343
+ resourceDepth: found.scope.depth,
344
+ };
345
+ }
346
+
347
+ // Cursor sits on a scalar value.
348
+ if (found?.type === "value" && found.valueNode.kind === "scalar") {
349
+ const value = found.valueNode;
350
+ const cel = celAt(value, offset);
351
+ // A bare identifier on its own line with no colon is a partial *key* being
352
+ // typed as a first child (yaml parses it as the parent's value). Route to a
353
+ // key position via column search — the documented cursor-line carve-out.
354
+ const lineText = text.slice(lineOffsets[line] ?? 0, lineOffsets[line + 1] ?? text.length);
355
+ const isPartialKey =
356
+ typeof value.value === "string" &&
357
+ !lineText.includes(":") &&
358
+ /^\s*[A-Za-z_][\w-]*\s*$/.test(lineText) &&
359
+ toPos(value.range[0]).line !== toPos(found.keyEnd).line;
360
+ if (isPartialKey && doc.root) {
361
+ const col = toPos(value.range[0]).character;
362
+ const { path, existingKeys, scope } = columnSearch(doc.root, col, offset, lineOffsets);
363
+ return {
364
+ docIndex,
365
+ docKind,
366
+ slot: "key",
367
+ path,
368
+ container: found.container,
369
+ existingKeys,
370
+ resourceKind: scope.kind,
371
+ resourceDepth: scope.depth,
372
+ };
373
+ }
374
+
375
+ const clampedEnd = Math.min(offset, value.range[1]);
376
+ return {
377
+ docIndex,
378
+ docKind,
379
+ slot: "value",
380
+ path: found.keyName != null ? [...found.path, found.keyName] : found.path,
381
+ node: value,
382
+ container: found.container,
383
+ prefix: text.slice(value.range[0], clampedEnd),
384
+ spaceAfterColon: value.range[0] - found.keyEnd >= 2,
385
+ siblingKind: found.container ? siblingKindOf(found.container) : undefined,
386
+ replaceRange: { start: toPos(value.range[0]), end: toPos(value.range[1]) },
387
+ cel,
388
+ };
389
+ }
390
+
391
+ // Empty space (blank line, trailing indent, empty document) → key position,
392
+ // resolved by cursor column.
393
+ const resolution: KeyResolution = doc.root
394
+ ? columnSearch(doc.root, character, offset, lineOffsets)
395
+ : { path: [], existingKeys: new Set<string>(), scope: { depth: 0 } };
396
+ return {
397
+ docIndex,
398
+ docKind,
399
+ slot: "key",
400
+ path: resolution.path,
401
+ existingKeys: resolution.existingKeys,
402
+ resourceKind: resolution.scope.kind,
403
+ resourceDepth: resolution.scope.depth,
404
+ };
405
+ }
@@ -6,3 +6,14 @@ export const CAPABILITY_VALUES = [
6
6
  "Telo.Mount",
7
7
  "Telo.Type",
8
8
  ] as const;
9
+
10
+ /** One-line role summary per capability, surfaced on hover. Kept in sync with
11
+ * the capability list in `CLAUDE.md` / the kernel builtins. */
12
+ export const CAPABILITY_DOCS: Record<string, string> = {
13
+ "Telo.Service": "Long-lived resource: `init()` + optional `teardown()` (servers, pools).",
14
+ "Telo.Runnable": "One-shot task: `run()` (pipelines, boot steps).",
15
+ "Telo.Invocable": "Request handler: `invoke(inputs)` (scripts, endpoints).",
16
+ "Telo.Provider": "Value-flow source: `init()` + optional `provide()` (config, secrets).",
17
+ "Telo.Mount": "Mounted into a Service (HTTP APIs, middleware).",
18
+ "Telo.Type": "Pure schema definition — no runtime instance.",
19
+ };
@@ -0,0 +1,123 @@
1
+ import {
2
+ parseToAst,
3
+ type AstDocument,
4
+ type LoadedFile,
5
+ type LoadedGraph,
6
+ type LoadedModule,
7
+ type Range,
8
+ } from "@telorun/analyzer";
9
+ import type { DefinitionResult } from "../types.js";
10
+ import { resolveNodeAtPosition } from "../completions/resolve-node.js";
11
+
12
+ /** The module whose owner or partials includes `filePath`. */
13
+ function moduleForFile(graph: LoadedGraph, filePath: string): LoadedModule | undefined {
14
+ for (const mod of graph.modules.values()) {
15
+ if (mod.owner.source === filePath) return mod;
16
+ if (mod.partials.some((p) => p.source === filePath)) return mod;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /** First resource named `name` across `files`, located at its `metadata.name`
22
+ * (or its first line as a fallback). Names are unique within a module scope, so
23
+ * the first hit is the definition. */
24
+ function locateResource(files: LoadedFile[], name: string): DefinitionResult | undefined {
25
+ for (const file of files) {
26
+ for (let i = 0; i < file.manifests.length; i++) {
27
+ const manifest = file.manifests[i];
28
+ if (!manifest || manifest.metadata?.name !== name) continue;
29
+ const pos = file.positions[i];
30
+ const range: Range | undefined =
31
+ pos?.positionIndex.get("metadata.name") ??
32
+ pos?.positionIndex.get("@key:metadata.name") ??
33
+ (pos ? { start: { line: pos.sourceLine, character: 0 }, end: { line: pos.sourceLine, character: 0 } } : undefined);
34
+ if (range) return { uri: file.source, range };
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /** The `exports.resources` list of a module's owner doc (empty when absent). */
41
+ function exportedResources(mod: LoadedModule): string[] {
42
+ const doc = mod.owner.manifests.find(
43
+ (m) => m?.kind === "Telo.Library" || m?.kind === "Telo.Application",
44
+ ) as { exports?: { resources?: unknown } } | undefined;
45
+ const list = doc?.exports?.resources;
46
+ return Array.isArray(list) ? list.filter((e): e is string => typeof e === "string") : [];
47
+ }
48
+
49
+ /** Follow `name` into `moduleSource` across the export boundary, honoring the
50
+ * `exports.resources` gate and re-export chains (`app → api → domain → …`). A
51
+ * terminal match is a locally-owned instance the module actually exports; a
52
+ * re-export entry `InnerAlias.name` hops through that module's own import edge.
53
+ * `seen` bounds cyclic import graphs. */
54
+ function resolveExported(
55
+ graph: LoadedGraph,
56
+ moduleSource: string,
57
+ name: string,
58
+ seen: Set<string> = new Set(),
59
+ ): DefinitionResult | undefined {
60
+ if (seen.has(moduleSource)) return undefined;
61
+ seen.add(moduleSource);
62
+ const mod = graph.modules.get(moduleSource);
63
+ if (!mod) return undefined;
64
+
65
+ const exports = exportedResources(mod);
66
+
67
+ // No `exports.resources` block → ungated (the module hasn't opted into the
68
+ // gate, same as `exports.kinds`): a plain name match keeps navigation working
69
+ // for modules that predate explicit exports.
70
+ if (exports.length === 0) {
71
+ return locateResource([mod.owner, ...mod.partials], name);
72
+ }
73
+
74
+ if (exports.includes(name)) {
75
+ const local = locateResource([mod.owner, ...mod.partials], name);
76
+ if (local) return local;
77
+ }
78
+
79
+ const reexport = exports.find((e) => e.endsWith(`.${name}`));
80
+ if (!reexport) return undefined;
81
+ const innerAlias = reexport.slice(0, reexport.length - name.length - 1);
82
+ const edge = graph.importEdges.get(mod.owner.source)?.get(innerAlias);
83
+ return edge ? resolveExported(graph, edge.targetSource, name, seen) : undefined;
84
+ }
85
+
86
+ /** Resolve the `!ref` under the cursor to its target resource's definition.
87
+ *
88
+ * The ref grammar mirrors `resolveRefSentinels`: the tag's value is split on
89
+ * the first dot — a bare name (or `Self.name`) is a local resource in the
90
+ * current module; `Alias.name` is an exported instance of the module the import
91
+ * `Alias` points at, followed transitively through re-exports and gated on each
92
+ * module's `exports.resources`. Returns `undefined` when the cursor isn't on a
93
+ * `!ref`, or the target can't be found (e.g. a scope-local name, an unexported
94
+ * instance, or an import that failed to load). */
95
+ export function buildDefinition(
96
+ text: string,
97
+ line: number,
98
+ character: number,
99
+ graph: LoadedGraph,
100
+ currentFilePath: string,
101
+ docs?: AstDocument[],
102
+ ): DefinitionResult | undefined {
103
+ const astDocs = docs ?? parseToAst(text);
104
+ const node = resolveNodeAtPosition(text, astDocs, line, character)?.node;
105
+ if (!node || node.kind !== "scalar" || node.tag !== "!ref") return undefined;
106
+
107
+ // The scalar's range covers the ref target text (the value after `!ref`).
108
+ const source = text.slice(node.range[0], node.range[1]).trim();
109
+ if (!source) return undefined;
110
+
111
+ const currentModule = moduleForFile(graph, currentFilePath) ?? graph.entry;
112
+ const dot = source.indexOf(".");
113
+ const alias = dot === -1 ? undefined : source.slice(0, dot);
114
+ const name = dot === -1 ? source : source.slice(dot + 1);
115
+
116
+ if (alias === undefined || alias === "Self") {
117
+ return locateResource([currentModule.owner, ...currentModule.partials], name);
118
+ }
119
+
120
+ const edge = graph.importEdges.get(currentModule.owner.source)?.get(alias);
121
+ if (!edge) return undefined;
122
+ return resolveExported(graph, edge.targetSource, name);
123
+ }
@@ -0,0 +1 @@
1
+ export { buildDefinition } from "./build-definition.js";
@@ -0,0 +1,149 @@
1
+ import {
2
+ parseToAst,
3
+ type AnalysisRegistry,
4
+ type AstDocument,
5
+ } from "@telorun/analyzer";
6
+ import type { HoverResult } from "../types.js";
7
+ import { navigateSchema } from "../completions/detect-context.js";
8
+ import {
9
+ resolveNodeAtPosition,
10
+ scalarString,
11
+ type ResolvedCursor,
12
+ } from "../completions/resolve-node.js";
13
+ import { CAPABILITY_DOCS } from "../completions/valid-capabilities.js";
14
+
15
+ type Definition = NonNullable<ReturnType<AnalysisRegistry["resolveDefinition"]>>;
16
+
17
+ /** Docs for the structural keys shared by every module doc, so hover is useful
18
+ * even at the root, where there is no user-authored schema to navigate. */
19
+ const STRUCTURAL_KEY_DOCS: Record<string, string> = {
20
+ kind: "The resource kind — `Alias.Name` for an imported kind, or a `Telo.*` root kind.",
21
+ metadata: "Resource identity: `name` (kebab-case, dot-free) and optional `namespace`.",
22
+ imports: "Dependency map: PascalCase alias → `namespace/name@version` source string or object.",
23
+ targets: "Boot sequence run after init — references to `Runnable`/`Service` resources or inline invoke steps.",
24
+ variables: "Typed inputs bound from host env vars (`env:` + JSON-Schema `type:`).",
25
+ secrets: "Secret inputs bound from host env vars (`env:` + `type:`).",
26
+ ports: "Inbound ports the app listens on, each bound to a host env var (Application only).",
27
+ exports: "What importers may reference: `kinds` (kind gate) and `resources` (instance singletons).",
28
+ include: "Partial files loaded into this module scope (paths / globs).",
29
+ capability: "The lifecycle role of the kind this definition registers.",
30
+ schema: "JSON Schema for the kind's config fields, with `x-telo-*` annotations.",
31
+ extends: "Alias-form kind this definition specializes (abstract contract or concrete parent).",
32
+ base: "Construction mapping (`super(...)`) over `self` for a concrete-`extends` definition.",
33
+ controllers: "Controller locator (`pkg:npm`) implementing this kind.",
34
+ };
35
+
36
+ /** The kind value of the map that directly encloses `keyName` in the value slot. */
37
+ function typeName(t: string | Record<string, any> | undefined): string | undefined {
38
+ if (typeof t === "string") return t;
39
+ if (t && typeof t === "object" && typeof t.title === "string") return t.title;
40
+ return undefined;
41
+ }
42
+
43
+ function kindHover(kind: string, def: Definition | undefined): string {
44
+ if (!def) return `\`${kind}\``;
45
+ const lines: string[] = [`### ${kind}`];
46
+ const role = def.capability ? `\`${def.capability}\`` : "resource";
47
+ const module = def.metadata?.module ? ` · module \`${def.metadata.module}\`` : "";
48
+ lines.push(`${role}${module}`);
49
+ const schema = def.schema as Record<string, any> | undefined;
50
+ const desc = schema?.description ?? schema?.title;
51
+ if (typeof desc === "string" && desc) lines.push("", desc);
52
+ if (def.extends) lines.push("", `Extends \`${def.extends}\``);
53
+ const input = typeName(def.inputType);
54
+ const output = typeName(def.outputType);
55
+ if (input) lines.push(`Input \`${input}\``);
56
+ if (output) lines.push(`Output \`${output}\``);
57
+ return lines.join("\n");
58
+ }
59
+
60
+ function fieldHover(keyName: string, field: Record<string, any>): string {
61
+ const lines: string[] = [];
62
+ const type = Array.isArray(field.type) ? field.type.join(" | ") : field.type;
63
+ const head = type ? `**${keyName}**: \`${type}\`` : `**${keyName}**`;
64
+ lines.push(head);
65
+ if (typeof field.description === "string" && field.description) {
66
+ lines.push("", field.description);
67
+ }
68
+ const ref = field["x-telo-ref"];
69
+ if (typeof ref === "string") lines.push("", `Reference → \`${ref}\``);
70
+ if (Array.isArray(field.enum) && field.enum.length > 0) {
71
+ lines.push("", `Allowed: ${field.enum.map((v: unknown) => `\`${v}\``).join(", ")}`);
72
+ }
73
+ if (field.default !== undefined) lines.push(`Default: \`${JSON.stringify(field.default)}\``);
74
+ return lines.length > 0 ? lines.join("\n") : `**${keyName}**`;
75
+ }
76
+
77
+ /** Field schema at the nearest enclosing resource, or undefined when the scope
78
+ * can't be resolved (no kind-bearing ancestor, or the path doesn't navigate). */
79
+ function fieldSchemaFor(
80
+ resourceKind: string | undefined,
81
+ relativePath: string[],
82
+ registry: AnalysisRegistry | undefined,
83
+ ): Record<string, any> | undefined {
84
+ if (!resourceKind || !registry) return undefined;
85
+ const def = registry.resolveDefinition(resourceKind);
86
+ if (!def?.schema) return undefined;
87
+ return navigateSchema(def.schema as Record<string, any>, relativePath);
88
+ }
89
+
90
+ export function buildHover(
91
+ text: string,
92
+ line: number,
93
+ character: number,
94
+ registry: AnalysisRegistry | undefined,
95
+ docs?: AstDocument[],
96
+ ): HoverResult | undefined {
97
+ const astDocs = docs ?? parseToAst(text);
98
+ const resolved = resolveNodeAtPosition(text, astDocs, line, character);
99
+ if (!resolved) return undefined;
100
+
101
+ if (resolved.slot === "value") return hoverForValue(resolved, registry);
102
+ return hoverForKey(resolved, registry);
103
+ }
104
+
105
+ function hoverForValue(
106
+ resolved: ResolvedCursor,
107
+ registry: AnalysisRegistry | undefined,
108
+ ): HoverResult | undefined {
109
+ const key = resolved.path[resolved.path.length - 1];
110
+ const value = scalarString(resolved.node);
111
+ const range = resolved.replaceRange;
112
+
113
+ if (key === "kind" && value) {
114
+ return { contents: kindHover(value, registry?.resolveDefinition(value)), range };
115
+ }
116
+ if (key === "capability" && resolved.docKind === "Telo.Definition" && value) {
117
+ const doc = CAPABILITY_DOCS[value];
118
+ return doc ? { contents: `**${value}**\n\n${doc}`, range } : undefined;
119
+ }
120
+
121
+ // Field value: describe the field via the enclosing resource's schema. Works
122
+ // when the value sits directly under a kind-bearing map (`siblingKind`); the
123
+ // field path relative to that map is just the key.
124
+ if (key) {
125
+ const field = fieldSchemaFor(resolved.siblingKind, [key], registry);
126
+ if (field) return { contents: fieldHover(key, field), range };
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function hoverForKey(
132
+ resolved: ResolvedCursor,
133
+ registry: AnalysisRegistry | undefined,
134
+ ): HoverResult | undefined {
135
+ const keyName = scalarString(resolved.node);
136
+ if (!keyName) return undefined;
137
+ const range = resolved.replaceRange;
138
+
139
+ const resourceKind = resolved.resourceKind ?? resolved.docKind;
140
+ const relativePath = [...resolved.path.slice(resolved.resourceDepth ?? 0), keyName];
141
+ const field = fieldSchemaFor(resourceKind, relativePath, registry);
142
+ if (field) return { contents: fieldHover(keyName, field), range };
143
+
144
+ const structural = STRUCTURAL_KEY_DOCS[keyName];
145
+ if (structural && relativePath.length === 1) {
146
+ return { contents: `**${keyName}**\n\n${structural}`, range };
147
+ }
148
+ return undefined;
149
+ }