import { parseToAst, readRefSlot, refSlotAnnotation, type AstDocument, type CelSegment, } from "@telorun/analyzer"; import type { ReplaceRange } from "../types.js"; import { resolveNodeAtPosition } from "./resolve-node.js"; export type { ReplaceRange }; export type CompletionCtx = | { type: "kind"; /** Set for indented `kind:` lines. The enclosing docKind + the YAML * path to the parent of the `kind:` field (so the value slot's * schema node can be looked up to discover `x-telo-ref` constraints). * Absent for top-level `kind:` — there, no constraint applies. */ docKind?: string; yamlPath?: string[]; /** Full source range of the kind value, so a pick overwrites the whole * existing scalar (e.g. `Sql.Co|nnection` + `Sql.Connection` → no * suffix left behind). */ replaceRange: ReplaceRange; } | { type: "capability" } | { type: "prop-key"; docKind: string; yamlPath: string[]; /** The same location with sequence indices kept, so a caller can find the * manifest node this slot sits in — what resolving an enclosing call's * reference needs. */ concretePath: string; docIndex: number; existingKeys: Set; } | { /** Cursor sits on the value of an object-form ref's `name:` field * (e.g. `connection: { kind: Sql.Connection, name: |}`). Editor hosts * use `refKind` (from the sibling `kind:` line) to filter the in-doc * resource list to matching candidates. */ type: "ref-name"; docKind: string; /** YAML path to the parent slot (e.g. `["connection"]`). The schema * at this path declares the `x-telo-ref` constraint. */ yamlPath: string[]; /** The kind value of the sibling `kind:` line, if present. */ refKind?: string; prefix: string; replaceRange: ReplaceRange; } | { type: "field-value"; docKind: string; field: string; /** Text from the start of the value to the cursor. */ prefix: string; /** Full source range of the value being completed. */ replaceRange: ReplaceRange; } | { /** Cursor sits on an ordinary field VALUE whose schema declares the values * it may take — `enum` (closed) or `examples` (open). Untargeted on * purpose: every value slot resolves here and the ones declaring neither * simply offer nothing. */ type: "value-suggestions"; docKind: string; /** Path from the document root to the field, so its schema can be found. */ yamlPath: string[]; replaceRange: ReplaceRange; } | { /** Cursor sits inside a CEL body — closed or still open (`!cel "req.|`). * What completes is decided by the scope the analyzer resolves for this * site, so the host must supply a `CelScopeQuery`; without one the * candidate list would be a guess rather than a claim about what * `telo check` accepts, and nothing is offered. */ type: "cel"; docKind?: string; /** Which `---` document the cursor is in, so the host can name the * resource this expression belongs to. */ docIndex: number; /** The site's address with sequence indices kept — what the scope is * resolved at. */ concretePath: string; segment: CelSegment; /** Cursor as a document offset. */ offset: number; }; /** Returns every schema branch reachable from `node` after peeling `anyOf` / * `oneOf` recursively. A branch with no combinators is its own only entry. * Used so an `x-telo-ref` slot like `{anyOf: [{type: string}, {type: object, * properties: …}]}` exposes the object branch's properties to completion. */ function peelCombinators(node: Record): Record[] { const out: Record[] = []; const visit = (n: any) => { if (!n || typeof n !== "object") return; const branches: any[] = []; if (Array.isArray(n.anyOf)) branches.push(...n.anyOf); if (Array.isArray(n.oneOf)) branches.push(...n.oneOf); if (branches.length === 0) { out.push(n); return; } for (const b of branches) visit(b); }; visit(node); return out; } /** One JSON Pointer segment: RFC 6901 escapes, then percent-decoding, which a * pointer carried in a URI fragment is subject to. A malformed escape is * returned raw rather than thrown — `decodeURIComponent` raises `URIError`, and * a stray `%` in someone's `$defs` key must not take completion down. */ function decodePointerSegment(segment: string): string { const unescaped = segment.replace(/~1/g, "/").replace(/~0/g, "~"); try { return decodeURIComponent(unescaped); } catch { return unescaped; } } /** * Follow a document-local `$ref` (`#/$defs/`) against the schema root. * * The one reference form that occurs inside a kind schema, and the one the * editor's resolver accepts — a schema-valued slot points at the hoisted * `JsonSchema7` / `KindSchema` fragment this way, and the fragment points at * itself to describe a nested schema. Without the hop, completion stopped dead * at the first key of every `schema:` / `status:` block. * * A chain ends at a REPEATED pointer, which is what bounds the walk: a * self-referential fragment is the normal case here, so a cycle must degrade to * "no completion" rather than hang the editor. */ function resolveLocalRef( node: Record | undefined, root: Record, ): Record | undefined { let current = node; const seen = new Set(); while (current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) { const pointer = current.$ref; if (seen.has(pointer)) return undefined; seen.add(pointer); let target: any = root; for (const segment of pointer.slice(2).split("/")) { target = target?.[decodePointerSegment(segment)]; } if (!target || typeof target !== "object") return undefined; // Keep whatever the slot declared beside the `$ref` (its title, its // `x-telo-fragment` stamp) — that is what tells a consumer WHICH shape it // pointed at, and draft-07 drops it at the validation layer only. const { $ref: _, ...siblings } = current; current = { ...target, ...siblings }; } return current; } /** * Resolves an `x-telo-schema-from` annotation to the schema it derives. Supplied * by the caller because the anchor is alias-qualified and only the registry can * resolve it in the declaring kind's module scope. */ export type SchemaFromResolver = (schemaFrom: string) => Record | undefined; /** * Expand a node whose shape comes from a sibling kind's schema. * * A slot annotated `x-telo-schema-from` declares NO `properties` of its own — an * `Http.Api` route's `request:` is exactly this — so every walk that reads * `properties` finds an empty node and silently offers nothing. The derived * schema is merged UNDER whatever the slot itself declared, so a slot that adds * a title or narrows a field keeps winning. */ function resolveSchemaFrom( node: Record | undefined, resolve: SchemaFromResolver | undefined, ): Record | undefined { const from = node?.["x-telo-schema-from"]; if (!node || !resolve || typeof from !== "string") return node; const derived = resolve(from); if (!derived) return node; return { ...derived, ...node, properties: { ...(derived.properties ?? {}), ...(node.properties ?? {}) }, }; } /** Navigate a JSON Schema hierarchy following `path`, auto-descending into * array items, peeling `anyOf` / `oneOf` branches, following document-local * `$ref`s and expanding `x-telo-schema-from` slots. When multiple peeled * branches define `properties`, returns a synthetic node whose `properties` is * the union (first-wins on key collision) and whose `required` is the * intersection — enough for propKeyCompletions to surface every key a value at * this slot can legally carry. */ export function navigateSchema( schema: Record, path: string[], schemaFrom?: SchemaFromResolver, ): Record | undefined { let current: Record | undefined = schema; for (const segment of path) { current = resolveSchemaFrom(resolveLocalRef(current, schema), schemaFrom); if (!current) return undefined; const candidates = peelCombinators(current).flatMap((node) => { const expanded: Record[] = []; let cur: Record = node; while (cur.type === "array" && cur.items) cur = cur.items as Record; for (const peeled of peelCombinators(cur)) expanded.push(peeled); return expanded; }); let next: Record | undefined; for (const cand of candidates) { const sub = (cand.properties as Record | undefined)?.[segment]; if (sub) { next = sub as Record; break; } } // A map-valued node (a schema's `properties:`, a kind's name-keyed field) // names its entries nowhere, so every segment lands on // `additionalProperties`. Without this the walk stopped one level into a // `schema:` block — at exactly the field the author is writing. if (!next) { for (const cand of candidates) { const additional = cand.additionalProperties; if (additional && typeof additional === "object") { next = additional as Record; break; } } } if (!next) return undefined; current = next; } current = resolveSchemaFrom(resolveLocalRef(current, schema), schemaFrom); if (!current) return undefined; // Auto-descend through a trailing array at the leaf (e.g. cursor inside `mounts:` items) while (current.type === "array" && current.items) { current = current.items as Record; } const leaves = peelCombinators(current); if (leaves.length === 1) return leaves[0]; return unionLeaves(current, leaves); } /** Merge multiple peeled schema branches into one node for completion purposes. * Property maps are unioned (first branch wins on key collision). `required` * becomes the intersection so optional-in-any-branch keys still surface. * * The parent's reference slot is re-stamped on the merged node, because peeling * is exactly what destroys it: for the canonical multi-kind shape * (`anyOf: [{x-telo-ref: A}, {x-telo-ref: B}]`) the constraint lives ONLY in the * branches, and merging them left a node declaring no slot at all. Re-emitted * through the analyzer's accessor so both annotation shapes survive the merge. */ function unionLeaves( parent: Record, leaves: Record[], ): Record { const properties: Record = {}; const requiredSets: Set[] = []; for (const leaf of leaves) { const props = leaf.properties as Record | undefined; if (props) { for (const [k, v] of Object.entries(props)) { if (!(k in properties)) properties[k] = v; } } requiredSets.push( new Set(Array.isArray(leaf.required) ? (leaf.required as string[]) : []), ); } let required: string[] = []; if (requiredSets.length > 0) { required = [...requiredSets[0]].filter((k) => requiredSets.every((s) => s.has(k)), ); } const out: Record = { type: "object", properties, required }; const slot = readRefSlot(parent); if (slot && slot.kinds.length > 0) out["x-telo-ref"] = refSlotAnnotation(slot); return out; } /** Walks up and down from `cursorLine` looking for a sibling line at the * exact same indent whose key is `kind`. The value of the first such line * is returned (alias form, e.g. `"Sql.Connection"`). Used by ref-name * completion to discover what kind of resource the user is targeting in an * object-form ref. Walking stops at the first line with a strictly smaller * indent (that's the parent's structural boundary). */ /** Every kind the `x-telo-ref` slot at `yamlPath` accepts, or an empty array * when the path doesn't resolve or declares no constraint. * * All of them, not the first: a slot accepting `Invocable | Runnable` used to * complete only the invocables, because a single-constraint lookup stopped at * the first branch. The analyzer's accessor unions a `kind:` list and the * `anyOf` / `oneOf` branches alike, so completion now offers what the slot * actually takes. */ export function lookupRefConstraints( definitionSchema: Record, yamlPath: string[], schemaFrom?: SchemaFromResolver, ): string[] { const node = navigateSchema(definitionSchema, yamlPath, schemaFrom); if (!node) return []; return readRefSlot(node)?.kinds ?? []; } /** Derive a `CompletionCtx` from the AST-resolved cursor (Approach B). The * structural resolution lives in `resolveNodeAtPosition`; this only maps a * resolved slot onto the completion the editor should offer. `docs` lets a * host thread its already-parsed AST; without it we parse locally so this * stands alone. */ export function detectContext( text: string, line: number, character: number, docs?: AstDocument[], ): CompletionCtx | undefined { const resolved = resolveNodeAtPosition(text, docs ?? parseToAst(text), line, character); if (!resolved) return undefined; const { docKind } = resolved; if (resolved.slot === "value") { // Inside a CEL body — structural completion does not apply; what completes // are the names the expression may use. if (resolved.cel) { return { type: "cel", docKind, docIndex: resolved.docIndex, concretePath: resolved.concretePath ?? "", segment: resolved.cel.segment, offset: resolved.cel.offset, }; } const replaceRange = resolved.replaceRange; if (!replaceRange) return undefined; const key = resolved.path[resolved.path.length - 1]; const parentPath = resolved.path.slice(0, -1); const prefix = resolved.prefix ?? ""; if (key === "kind") { if (parentPath.length === 0) return { type: "kind", replaceRange }; if (docKind) return { type: "kind", docKind, yamlPath: parentPath, replaceRange }; return undefined; } if (key === "capability" && docKind === "Telo.Definition") { return { type: "capability" }; } // Any `name:` value is a candidate ref target; the sibling `kind:` (when // present) narrows the in-file resource list. Harmless on `metadata.name`, // where no ref constraint resolves and the list is the fallback. if (key === "name" && docKind) { return { type: "ref-name", docKind, yamlPath: parentPath, refKind: resolved.siblingKind, prefix, replaceRange, }; } // Import-source: the scalar shorthand `imports.` or the object-form // `imports..source`. `spaceAfterColon` distinguishes `Console: ` (a // value) from a bare `Tiny:` header about to carry a nested `source:`. if ( (docKind === "Telo.Application" || docKind === "Telo.Library") && resolved.spaceAfterColon ) { const isScalarEntry = parentPath.length === 1 && parentPath[0] === "imports"; const isObjectSource = key === "source" && parentPath.length === 2 && parentPath[0] === "imports"; if (isScalarEntry || isObjectSource) { return { type: "field-value", docKind, field: "import-source", prefix, replaceRange }; } } if (docKind) { return { type: "value-suggestions", docKind, yamlPath: resolved.path, replaceRange }; } return undefined; } // Key position (existing key, blank line, or trailing indent). Complete // against the nearest enclosing inline resource's schema (or the root // resource), with the path made relative to it — so a prop key inside // `mount: { kind: Crud.Resource, … }` offers Crud.Resource's fields, not the // outer ref slot's. const scopeKind = resolved.resourceKind ?? docKind; if (!scopeKind) return undefined; return { type: "prop-key", docKind: scopeKind, yamlPath: resolved.path.slice(resolved.resourceDepth ?? 0), concretePath: resolved.concretePath ?? "", docIndex: resolved.docIndex, existingKeys: resolved.existingKeys ?? new Set(), }; }