import { CelParseError, parseToAst, readRefSlot, type AnalysisRegistry, type AstDocument, type CelNode, type CelScopeQuery, type ManifestAnalysis, } from "@telorun/analyzer"; import type { HoverResult } from "../types.js"; import { chainAt } from "../cel-chain.js"; import { celSymbolAt } from "../cel/symbols.js"; import { docIdentity } from "../doc-identity.js"; import { navigateSchema } from "../completions/detect-context.js"; import { resolveNodeAtPosition, scalarString, type ResolvedCursor, } from "../completions/resolve-node.js"; import { CAPABILITY_DOCS } from "../completions/valid-capabilities.js"; type Definition = NonNullable>; /** Docs for the structural keys shared by every module doc, so hover is useful * even at the root, where there is no user-authored schema to navigate. */ const STRUCTURAL_KEY_DOCS: Record = { kind: "The resource kind — `Alias.Name` for an imported kind, or a `Telo.*` root kind.", metadata: "Resource identity: `name` (kebab-case, dot-free) and optional `namespace`.", imports: "Dependency map: PascalCase alias → `namespace/name@version` source string or object.", targets: "Boot sequence run after init — references to `Runnable`/`Service` resources or inline invoke steps.", variables: "Typed inputs bound from host env vars (`env:` + JSON-Schema `type:`).", secrets: "Secret inputs bound from host env vars (`env:` + `type:`).", ports: "Inbound ports the app listens on, each bound to a host env var (Application only).", exports: "What importers may reference: `kinds` (kind gate) and `resources` (instance singletons).", include: "Partial files loaded into this module scope (paths / globs).", capability: "The lifecycle role of the kind this definition registers.", schema: "JSON Schema for the kind's config fields, with `x-telo-*` annotations.", extends: "Alias-form kind this definition specializes (abstract contract or concrete parent).", base: "Construction mapping (`super(...)`) over `self` for a concrete-`extends` definition.", controllers: "Controller locator (`pkg:npm`) implementing this kind.", }; /** The kind value of the map that directly encloses `keyName` in the value slot. */ function typeName(t: string | Record | undefined): string | undefined { if (typeof t === "string") return t; if (t && typeof t === "object" && typeof t.title === "string") return t.title; return undefined; } function kindHover(kind: string, def: Definition | undefined): string { if (!def) return `\`${kind}\``; const lines: string[] = [`### ${kind}`]; const role = def.capability ? `\`${def.capability}\`` : "resource"; const module = def.metadata?.module ? ` · module \`${def.metadata.module}\`` : ""; lines.push(`${role}${module}`); const schema = def.schema as Record | undefined; const desc = schema?.description ?? schema?.title; if (typeof desc === "string" && desc) lines.push("", desc); if (def.extends) lines.push("", `Extends \`${def.extends}\``); const input = typeName(def.inputType); const output = typeName(def.outputType); if (input) lines.push(`Input \`${input}\``); if (output) lines.push(`Output \`${output}\``); return lines.join("\n"); } function fieldHover(keyName: string, field: Record): string { const lines: string[] = []; const type = Array.isArray(field.type) ? field.type.join(" | ") : field.type; const head = type ? `**${keyName}**: \`${type}\`` : `**${keyName}**`; lines.push(head); if (typeof field.description === "string" && field.description) { lines.push("", field.description); } // Through the shared accessor, so a slot whose constraint sits in an `anyOf` // branch — or in a `kind:` list — hovers like any other. Reading the raw // annotation here meant those slots showed no reference line at all. const refKinds = readRefSlot(field)?.kinds ?? []; if (refKinds.length > 0) { lines.push("", `Reference → ${refKinds.map((k) => `\`${k}\``).join(" | ")}`); } if (Array.isArray(field.enum) && field.enum.length > 0) { lines.push("", `Allowed: ${field.enum.map((v: unknown) => `\`${v}\``).join(", ")}`); } if (field.default !== undefined) lines.push(`Default: \`${JSON.stringify(field.default)}\``); return lines.length > 0 ? lines.join("\n") : `**${keyName}**`; } /** Field schema at the nearest enclosing resource, or undefined when the scope * can't be resolved (no kind-bearing ancestor, or the path doesn't navigate). */ function fieldSchemaFor( resourceKind: string | undefined, relativePath: string[], registry: AnalysisRegistry | undefined, ): Record | undefined { if (!resourceKind || !registry) return undefined; const def = registry.resolveDefinition(resourceKind); if (!def?.schema) return undefined; return navigateSchema(def.schema as Record, relativePath, (from) => registry.resolveSchemaFrom(from, resourceKind), ); } export function buildHover( text: string, line: number, character: number, registry: AnalysisRegistry | undefined, docs?: AstDocument[], /** The host's analysis. Without it a CEL identifier hovers as nothing — its * type is a property of the resolved scope, and there is no second source * for it. */ analysis?: ManifestAnalysis, ): HoverResult | undefined { const astDocs = docs ?? parseToAst(text); const resolved = resolveNodeAtPosition(text, astDocs, line, character); if (!resolved) return undefined; if (resolved.cel) { const hover = hoverForCel(resolved, astDocs, analysis?.celScope); // A CEL body is still a field value; when the cursor is on nothing // nameable inside it (an operator, a literal), fall through to the field's // own hover rather than reporting nothing. if (hover) return hover; } if (resolved.slot === "value") return hoverForValue(resolved, registry); return hoverForKey(resolved, registry); } /** * Hover for one identifier of a CEL chain. * * The TYPE comes from the resolved scope; a DESCRIPTION comes from whatever * schema node declared the name. Where to jump is the other half's answer * (`resolveCelTarget`) and is deliberately not consulted here — hover must * still say what `steps.encode.result` IS even though nothing in the manifest * declares it. */ function hoverForCel( resolved: ResolvedCursor, docs: AstDocument[], scopeQuery: CelScopeQuery | undefined, ): HoverResult | undefined { if (!resolved.cel || !scopeQuery) return undefined; const identity = docIdentity(docs[resolved.docIndex]); const resource = scopeQuery.resourceFor(identity.kind, identity.name); if (!resource) return undefined; let ast: CelNode; try { ast = resolved.cel.segment.ast(); } catch (error) { // An expression the author is still writing does not parse. That means // there is no chain to hit-test, not an error to report from a hover — the // analyzer reports the syntax error itself. Only that failure is tolerated. if (!(error instanceof CelParseError)) throw error; return undefined; } const hit = chainAt(ast, resolved.cel.offset); if (!hit) return undefined; // The chain UP TO the cursor, not the whole chain: hovering `resources` in // `resources.db.url` describes `resources`. const parts = hit.parts.slice(0, hit.index + 1).map((p) => p.name); const scope = scopeQuery.scopeAt(resource, resolved.concretePath ?? ""); const symbol = celSymbolAt(scope, parts); if (!symbol) return undefined; const lines = [symbol.type ? `**${symbol.name}**: \`${symbol.type}\`` : `**${symbol.name}**`]; if (symbol.description) lines.push("", symbol.description); const chainText = parts.join("."); if (chainText !== symbol.name) lines.push("", `\`${chainText}\``); return { contents: lines.join("\n") }; } function hoverForValue( resolved: ResolvedCursor, registry: AnalysisRegistry | undefined, ): HoverResult | undefined { const key = resolved.path[resolved.path.length - 1]; const value = scalarString(resolved.node); const range = resolved.replaceRange; if (key === "kind" && value) { return { contents: kindHover(value, registry?.resolveDefinition(value)), range }; } if (key === "capability" && resolved.docKind === "Telo.Definition" && value) { const doc = CAPABILITY_DOCS[value]; return doc ? { contents: `**${value}**\n\n${doc}`, range } : undefined; } // Field value: describe the field via the enclosing resource's schema. Works // when the value sits directly under a kind-bearing map (`siblingKind`); the // field path relative to that map is just the key. if (key) { const field = fieldSchemaFor(resolved.siblingKind, [key], registry); if (field) return { contents: fieldHover(key, field), range }; } return undefined; } function hoverForKey( resolved: ResolvedCursor, registry: AnalysisRegistry | undefined, ): HoverResult | undefined { const keyName = scalarString(resolved.node); if (!keyName) return undefined; const range = resolved.replaceRange; const resourceKind = resolved.resourceKind ?? resolved.docKind; const relativePath = [...resolved.path.slice(resolved.resourceDepth ?? 0), keyName]; const field = fieldSchemaFor(resourceKind, relativePath, registry); if (field) return { contents: fieldHover(keyName, field), range }; const structural = STRUCTURAL_KEY_DOCS[keyName]; if (structural && relativePath.length === 1) { return { contents: `**${keyName}**\n\n${structural}`, range }; } return undefined; }