diff --git a/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md b/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md new file mode 100644 index 00000000000..ef4eddb17b3 --- /dev/null +++ b/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/bundler" +--- + +Include a library's own `tspconfig.yaml` in the generated bundle so per-library opt-ins (such as compiler `features`) are preserved when the library is loaded in the browser (e.g. in the playground). diff --git a/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md new file mode 100644 index 00000000000..9e496b99289 --- /dev/null +++ b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md @@ -0,0 +1,20 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add a new experimental `$onInfo` library hook and `program.getTypeInfo(type)` API allowing libraries to contribute extra, domain-specific information about types. Unlike `$onValidate`, this hook never runs during compilation and must not mutate the type graph — it is invoked lazily and on demand (e.g. by the language server for hover documentation, or by tooling querying the type). + +The hook is gated by the `type-info-hook` compiler feature, scoped to the package that declares it: a library opts in via its own `tspconfig.yaml` and consumers do not need to enable anything. + +```ts +// A library exports a provider (use `defineInfoHook` for typing): +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") return undefined; + return { content: "extra info about this operation" }; +}); + +// Tooling / language server queries it (merges every library's contribution): +const info = program.getTypeInfo(type); +``` diff --git a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md new file mode 100644 index 00000000000..5ac628b4cda --- /dev/null +++ b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md @@ -0,0 +1,12 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Add an `$onInfo` provider that surfaces the resolved HTTP route (verb and URI template) and response status codes of an operation. This is shown when hovering an operation in the IDE and can be queried programmatically via `program.getTypeInfo(operation)`. + +```ts +const info = program.getTypeInfo(operation); +// { content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`" } +``` diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 08768940467..5e010a641d4 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -186,6 +186,13 @@ async function createEsBuildContext( [normalizePath(join(libraryPath, "package.json"))]: JSON.stringify(definition.packageJson), }; + // Include the library's own `tspconfig.yaml` so per-library opt-ins (e.g. compiler + // `features`) are preserved in the bundle. + const tspconfig = await tryReadFile(join(libraryPath, "tspconfig.yaml")); + if (tspconfig !== undefined) { + typespecFiles[normalizePath(join(libraryPath, "tspconfig.yaml"))] = tspconfig; + } + for (const [filename, sourceFile] of program.sourceFiles) { typespecFiles[filename] = sourceFile.file.text; } @@ -339,6 +346,18 @@ async function readLibraryPackageJson(path: string): Promise { return JSON.parse(file.toString()); } +/** Read a file, returning `undefined` when it does not exist. */ +async function tryReadFile(path: string): Promise { + try { + return (await readFile(path)).toString(); + } catch (error: any) { + if (error.code === "ENOENT") { + return undefined; + } + throw error; + } +} + /** * Resolve which peer dependencies should be treated as external. * Only peer dependencies that are TypeSpec libraries (have `tspMain` in their package.json) diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 09a5e5b5108..51aafe8307c 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -1,5 +1,6 @@ import { mutate } from "../utils/misc.js"; import { compilerAssert } from "./diagnostics.js"; +import { isCompilerFeatureEnabled } from "./features.js"; import { getLocationContext } from "./helpers/location-context.js"; import { visitChildren } from "./parser.js"; import type { Program } from "./program.js"; @@ -179,6 +180,19 @@ export function createBinder(program: Program): Binder { : ({ type: "file" } satisfies FileLibraryMetadata); program.onValidate(member as any, metadata); continue; + } else if (name === "onInfo") { + // `$onInfo` is experimental: only respect it when the library (or project) that + // declares the hook opted into the `type-info-hook` feature in its own + // `tspconfig.yaml`. Consumers do not need to enable anything. + if (isCompilerFeatureEnabled(program, "type-info-hook", sourceFile)) { + const context = getLocationContext(program, sourceFile); + const metadata = + context.type === "library" + ? context.metadata + : ({ type: "file" } satisfies FileLibraryMetadata); + program.registerInfoProvider(member as any, metadata); + } + continue; } else if (name === "onEmit") { // nothing to do here this is loaded as emitter. continue; diff --git a/packages/compiler/src/core/external-error.ts b/packages/compiler/src/core/external-error.ts index 5f52e8248a3..5c05f133527 100644 --- a/packages/compiler/src/core/external-error.ts +++ b/packages/compiler/src/core/external-error.ts @@ -30,7 +30,7 @@ export type Colors = | "bgWhite"; export interface ExternalErrorInfo { - kind: "emitter" | "validator"; + kind: "emitter" | "validator" | "info"; error: unknown; metadata: LibraryMetadata; } @@ -56,7 +56,9 @@ function renderExternalErrorInfo( color( kind === "emitter" ? `Emitter "${metadata.name}" crashed! This is a bug.` - : `Library "${metadata.name}" $onValidate crashed! This is a bug.`, + : kind === "info" + ? `Library "${metadata.name}" $onInfo crashed! This is a bug.` + : `Library "${metadata.name}" $onValidate crashed! This is a bug.`, "red", ), ]; diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index eaeeda6cb9b..52ccc994fc5 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -15,6 +15,10 @@ export const compilerFeatures = { description: "Allows use of auto decorator declarations without experimental warnings in project code.", }, + "type-info-hook": { + description: + "Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", + }, } as const satisfies Record; export type CompilerFeatureName = keyof typeof compilerFeatures; diff --git a/packages/compiler/src/core/library.ts b/packages/compiler/src/core/library.ts index e8b596bc1b3..9cdc091a6e5 100644 --- a/packages/compiler/src/core/library.ts +++ b/packages/compiler/src/core/library.ts @@ -8,6 +8,7 @@ import { JSONSchemaValidator, LinterDefinition, LinterRuleDefinition, + OnInfoHook, PackageFlags, StateDef, TypeSpecLibrary, @@ -114,6 +115,23 @@ export function defineLinter(def: LinterDefinition): LinterDefinition { return def; } +/** + * Define a `$onInfo` hook that contributes extra information about types to IDE hover + * documentation and tooling. This helper only provides typing; export the result as `$onInfo` + * from your library's entry point. + * + * @example + * ```ts + * export const $onInfo = defineInfoHook(({ target }) => { + * if (target.kind !== "Operation") return undefined; + * return { content: `Operation ${target.name}` }; + * }); + * ``` + */ +export function defineInfoHook(hook: OnInfoHook): OnInfoHook { + return hook; +} + /** Create a new linter rule. */ export function createLinterRule< const N extends string, diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 188a1ded0c1..75663863d03 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -60,6 +60,7 @@ import { EmitContext, EmitterFunc, Entity, + InfoContext, JsSourceFileNode, LibraryInstance, LibraryMetadata, @@ -70,6 +71,7 @@ import { Namespace, NoTarget, Node, + OnInfoHook, PerfReporter, SourceFile, Sym, @@ -78,6 +80,7 @@ import { TemplateInstanceTarget, Tracer, Type, + TypeInfo, TypeSpecLibrary, TypeSpecScriptNode, } from "./types.js"; @@ -118,6 +121,22 @@ export interface Program { cb: (program: Program) => void | Promise, LibraryMetadata: LibraryMetadata, ): void; + /** + * Register a provider that contributes extra information about types. Libraries wire this up + * by exporting a `$onInfo` hook; it is discovered automatically by the compiler. + * @internal + */ + registerInfoProvider(provider: OnInfoHook, metadata: LibraryMetadata): void; + /** + * Query the registered `$onInfo` providers for extra information about the given type. Returns + * the merged information contributed by every library, or `undefined` when none contributed. + * Providers are run lazily and never mutate the type graph. Used by the language server for + * hover docs and by tooling. + * + * Only libraries that opted into the experimental `type-info-hook` feature in their own + * `tspconfig.yaml` contribute; consumers do not need to enable anything. + */ + getTypeInfo(target: Type): TypeInfo | undefined; /** @internal */ getOption(key: string): string | undefined; stateSet(key: symbol): Set; @@ -177,6 +196,11 @@ interface Validator { ) => void | readonly Diagnostic[] | Promise | Promise; } +interface InfoProvider { + metadata: LibraryMetadata; + callback: OnInfoHook; +} + interface TypeSpecLibraryReference { path: string; manifest: PackageJson; @@ -238,6 +262,7 @@ async function createProgram( ): Promise<{ program: Program; shouldAbort: boolean }> { const runtimeStats: Partial = {}; const validateCbs: Validator[] = []; + const infoProviders: InfoProvider[] = []; const stateMaps = new Map>(); const stateSets = new Map>(); const diagnostics: Diagnostic[] = []; @@ -301,6 +326,39 @@ async function createProgram( onValidate(cb, metadata) { validateCbs.push({ callback: cb, metadata }); }, + registerInfoProvider(provider, metadata) { + infoProviders.push({ callback: provider, metadata }); + }, + getTypeInfo(target) { + if (infoProviders.length === 0) { + return undefined; + } + const contents: string[] = []; + const context: InfoContext = { program, target }; + for (const provider of infoProviders) { + let result: TypeInfo | undefined; + try { + result = provider.callback(context); + } catch (error: any) { + // This runs lazily, long after compilation finished, so a crashing provider must not + // mutate the program's diagnostics. In a design time build (language server) trace it + // and carry on so a broken library cannot take down hover or completion. + if (options.designTimeBuild) { + trace( + "info-provider.crash", + `Library "${provider.metadata.name ?? ""}" $onInfo crashed: ${error.stack}`, + ); + continue; + } else { + throw new ExternalError({ kind: "info", metadata: provider.metadata, error }); + } + } + if (result) { + contents.push(result.content); + } + } + return contents.length > 0 ? { content: contents.join("\n\n") } : undefined; + }, getGlobalNamespaceType, resolveTypeReference, /** @internal */ diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 9f6a3ab9e48..37928de3894 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2351,6 +2351,37 @@ export type SemanticNodeListener = { } & TypeListeners & ValueListeners; +/** + * Extra information about a type contributed by a library through the `$onInfo` hook. Used to + * enrich IDE hover documentation and to answer queries (e.g. from AI agents/tooling). + */ +export interface TypeInfo { + /** Markdown content describing this information, e.g. ``"`HTTP Route`: `GET /pets/{id}`"``. */ + readonly content: string; +} + +/** + * Context passed to a library's `$onInfo` hook. Additional properties may be added over time. + */ +export interface InfoContext { + /** The current program. */ + readonly program: Program; + /** The type the information is being requested for. */ + readonly target: Type; +} + +/** + * Library hook returning extra information about a given type. + * + * A library provides this by exporting a `$onInfo` function (typically via {@link defineInfoHook}). + * Unlike `$onValidate`, this hook is never run during compilation and must not mutate the type + * graph. It is invoked lazily and on demand (e.g. by the language server when computing hover + * documentation, or by tooling querying {@link Program.getTypeInfo}). + * + * This hook is gated behind the experimental `type-info-hook` compiler feature. + */ +export type OnInfoHook = (context: InfoContext) => TypeInfo | undefined; + export type DiagnosticReportWithoutTarget< T extends { [code: string]: DiagnosticMessages }, C extends keyof T, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 57bbe1acb87..583d53fd0bd 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -101,6 +101,7 @@ export { export { createLinterRule as createRule, createTypeSpecLibrary, + defineInfoHook, defineLinter, definePackageFlags, paramMessage, @@ -379,6 +380,7 @@ export type { IdentifierContext, IdentifierKind, IndeterminateEntity, + InfoContext, InsertTextCodeFixEdit, Interface, IntrinsicScalarName, @@ -425,6 +427,7 @@ export type { NumericValue, ObjectValue, ObjectValuePropertyDescriptor, + OnInfoHook, Operation, OperationSignature, PackageFlags, @@ -468,6 +471,7 @@ export type { TracerOptions, Tuple, Type, + TypeInfo, TypeInstantiationMap, TypeListeners, TypeMapper, diff --git a/packages/compiler/src/server/type-details.ts b/packages/compiler/src/server/type-details.ts index 218399989d2..44bfbc7a229 100644 --- a/packages/compiler/src/server/type-details.ts +++ b/packages/compiler/src/server/type-details.ts @@ -64,9 +64,37 @@ export async function getSymbolDetails( ); } + const infoType = resolveSymbolType(program, symbol); + if (infoType) { + const info = program.getTypeInfo(infoType); + if (info) { + // Separate library contributed info from the type's own signature/documentation with a + // horizontal rule so it is clearly not part of the doc comment. + if (lines.length > 0) { + lines.push("---"); + } + lines.push(info.content); + } + } + return lines.join("\n\n"); } +/** Resolve the {@link Type} a symbol refers to, if any. */ +function resolveSymbolType(program: Program, symbol: Sym): Type | undefined { + if (symbol.type) { + return symbol.type; + } + const symNode = getSymNode(symbol); + if (symNode) { + const entity = program.checker.getTypeOrValueForNode(symNode); + if (entity && isType(entity)) { + return entity; + } + } + return undefined; +} + function getSymbolDocumentation(program: Program, symbol: Sym) { const docs: string[] = []; diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 86fb69c71dd..e2ed72afbc6 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -22,5 +22,6 @@ it("lists available compiler features and marks enabled features", () => { "", " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", + " disabled type-info-hook Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ]); }); diff --git a/packages/compiler/test/info-hook.test.ts b/packages/compiler/test/info-hook.test.ts new file mode 100644 index 00000000000..06b24a93039 --- /dev/null +++ b/packages/compiler/test/info-hook.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { ExternalError } from "../src/core/external-error.js"; +import type { CompilerOptions } from "../src/core/options.js"; +import type { InfoContext } from "../src/core/types.js"; +import { mockFile, t } from "../src/testing/index.js"; +import { Tester } from "./tester.js"; + +/** Root project config enabling the `type-info-hook` feature for the project's own files. */ +const projectFeature: { compilerOptions: CompilerOptions } = { + compilerOptions: { + configFile: { + projectRoot: ".", + kind: "project", + features: ["type-info-hook"], + diagnostics: [], + outputDir: "tsp-output", + }, + }, +}; + +const opInfoHook = (content: string) => + mockFile.js({ + $onInfo: ({ target }: InfoContext) => (target.kind === "Operation" ? { content } : undefined), + }); + +describe("compiler: $onInfo hook", () => { + it("merges the content contributed by multiple providers into a single result", async () => { + const runner = await Tester.files({ + "info1.js": opInfoHook("a"), + "info2.js": opInfoHook("b"), + }) + .import("./info1.js", "./info2.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "a\n\nb" }); + }); + + it("returns undefined when no provider contributes for the type", async () => { + const runner = await Tester.files({ "info.js": opInfoHook("a") }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`model ${t.model("foo")} {}`, projectFeature); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + }); + + it("ignores the hook when the declaring project did not enable the `type-info-hook` feature", async () => { + const runner = await Tester.files({ "info.js": opInfoHook("a") }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + }); + + const crashingHook = mockFile.js({ + $onInfo: () => { + throw new Error("boom"); + }, + }); + + it("wraps provider crashes in an ExternalError", async () => { + const runner = await Tester.files({ + "info.js": crashingHook, + }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); + expect(() => runner.program.getTypeInfo(foo)).toThrow(ExternalError); + }); + + it("swallows provider crashes in a design time build without mutating program diagnostics", async () => { + const runner = await Tester.files({ "info.js": crashingHook }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, { + compilerOptions: { ...projectFeature.compilerOptions, designTimeBuild: true }, + }); + + // `getTypeInfo` runs long after compilation, so a crash must not append to the (cached) + // program's diagnostics nor break the editor. + const before = runner.program.diagnostics.length; + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + expect(runner.program.diagnostics.length).toBe(before); + }); + + it("keeps collecting from healthy providers when one crashes in a design time build", async () => { + const runner = await Tester.files({ + "crash.js": crashingHook, + "ok.js": opInfoHook("still here"), + }) + .import("./crash.js", "./ok.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, { + compilerOptions: { ...projectFeature.compilerOptions, designTimeBuild: true }, + }); + + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "still here" }); + }); + + describe("feature is scoped to the library declaring the hook", () => { + function testerWithLib(tspconfig?: string) { + return Tester.files({ + "node_modules/my-lib/package.json": JSON.stringify({ name: "my-lib", main: "index.js" }), + "node_modules/my-lib/index.js": opInfoHook("from-lib"), + ...(tspconfig ? { "node_modules/my-lib/tspconfig.yaml": tspconfig } : {}), + }).import("my-lib"); + } + + it("respects the hook when the library enables the feature in its own tspconfig.yaml", async () => { + const runner = await testerWithLib( + `kind: project\nfeatures:\n - type-info-hook\n`, + ).createInstance(); + + // The consumer does NOT enable the feature; the library's opt-in is enough. + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "from-lib" }); + }); + + it("ignores the hook when the library does not enable the feature", async () => { + const runner = await testerWithLib().createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + }); + + it("enabling the feature in the consumer project does not enable it for library code", async () => { + const runner = await testerWithLib().createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + }); + }); +}); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 09680b8c713..e167564bb9d 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,19 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"'], + expected: ['"auto-decorators"', '"function-declarations"', '"type-info-hook"'], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "type-info-hook"], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "type-info-hook"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"'], + expected: ['"auto-decorators"', '"type-info-hook"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -159,6 +159,7 @@ describe("Test completion items for features", () => { [ "Allows use of auto decorator declarations without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", + "Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ], true, ); diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 262b364a871..bd42fa50bb5 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -836,6 +836,31 @@ describe("template access", () => { }); }); +describe("$onInfo hook", () => { + it("appends info contributed by a library to the hover, separated by a horizontal rule", async () => { + const hover = await getHoverAtCursorWithInfoHook(` + import "./info.js"; + op wr┆ite(): void; + `); + const value = getHoverValue(hover); + ok(value); + ok( + value.endsWith("\n\n---\n\nop-info:write"), + `Expected info separated from the signature by a horizontal rule, got: ${value}`, + ); + }); + + it("does not add anything when the provider returns undefined for the type", async () => { + const hover = await getHoverAtCursorWithInfoHook(` + import "./info.js"; + model Fo┆o {} + `); + const value = getHoverValue(hover); + ok(value); + ok(!value.includes("op-info:"), `Expected no info entry, got: ${value}`); + }); +}); + async function getHoverAtCursor(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); @@ -849,6 +874,21 @@ async function getHoverAtCursor(sourceWithCursor: string): Promise { + const { source, pos } = extractCursor(sourceWithCursor); + const testHost = await createTestServerHost(); + testHost.addOrUpdateDocument("tspconfig.yaml", `kind: project\nfeatures:\n - type-info-hook\n`); + testHost.addJsFile("info.js", { + $onInfo: ({ target }: { target: { kind: string; name?: string } }) => + target.kind === "Operation" ? { content: `op-info:${target.name}` } : undefined, + }); + const textDocument = testHost.addOrUpdateDocument("test.tsp", source); + return await testHost.server.getHover({ + textDocument, + position: textDocument.positionAt(pos), + }); +} + /** Normalize hover contents into a single comparable string for assertions. */ function getHoverValue(hover: Hover | undefined): string | undefined { if (!hover) return undefined; diff --git a/packages/http/package.json b/packages/http/package.json index 368611b2d53..2f2aa33c37b 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -70,6 +70,7 @@ }, "files": [ "lib/**/*.tsp", + "tspconfig.yaml", "dist/**", "!dist/test/**" ], diff --git a/packages/http/src/info.ts b/packages/http/src/info.ts new file mode 100644 index 00000000000..e11fc60df7b --- /dev/null +++ b/packages/http/src/info.ts @@ -0,0 +1,39 @@ +import { defineInfoHook } from "@typespec/compiler"; +import { getHttpOperation } from "./operations.js"; +import type { HttpStatusCodeRange } from "./types.js"; + +/** + * Contribute HTTP specific information about types to IDEs (e.g. hover) and tooling. + * + * For an operation, this surfaces the resolved HTTP route (verb + URI template) plus basic + * response status codes so it can be shown on hover or queried programmatically. This hook never + * mutates the type graph. + */ +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") { + return undefined; + } + const [operation] = getHttpOperation(program, target); + if (!operation) { + return undefined; + } + + const lines = [`\`HTTP Route\`: \`${operation.verb.toUpperCase()} ${operation.uriTemplate}\``]; + + const statusCodes = operation.responses.map((response) => formatStatusCode(response.statusCodes)); + if (statusCodes.length > 0) { + lines.push(`\`Responses\`: ${statusCodes.map((code) => `\`${code}\``).join(", ")}`); + } + + return { content: lines.join("\n\n") }; +}); + +function formatStatusCode(statusCode: HttpStatusCodeRange | number | "*"): string { + if (statusCode === "*") { + return "*"; + } + if (typeof statusCode === "number") { + return String(statusCode); + } + return `${statusCode.start}-${statusCode.end}`; +} diff --git a/packages/http/src/tsp-index.ts b/packages/http/src/tsp-index.ts index 227f96d878d..db98e0e98bc 100644 --- a/packages/http/src/tsp-index.ts +++ b/packages/http/src/tsp-index.ts @@ -37,6 +37,7 @@ import { $plainData, } from "./private.decorators.js"; +export { $onInfo } from "./info.js"; export { $lib } from "./lib.js"; export { $onValidate } from "./validate.js"; diff --git a/packages/http/test/info.test.ts b/packages/http/test/info.test.ts new file mode 100644 index 00000000000..3c7ef32e1cb --- /dev/null +++ b/packages/http/test/info.test.ts @@ -0,0 +1,57 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { $onInfo } from "../src/info.js"; +import { Tester } from "./test-host.js"; + +describe("http: $onInfo", () => { + it("surfaces the verb, URI template and response status codes for an operation", async () => { + const { program, read } = await Tester.compile(t.code` + @route("/pets/{id}") @get op ${t.op("read")}(@path id: string): void; + `); + + expect($onInfo({ program, target: read })).toEqual({ + content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`", + }); + }); + + it("returns undefined for non-operation types", async () => { + const { program, Pet } = await Tester.compile(t.code` + model ${t.model("Pet")} {} + `); + + expect($onInfo({ program, target: Pet })).toBeUndefined(); + }); + + it("formats a status code range", async () => { + const { program, read } = await Tester.compile(t.code` + @error model Error { + @statusCode @minValue(400) @maxValue(499) code: int32; + } + @route("/pets") @get op ${t.op("read")}(): void | Error; + `); + + expect($onInfo({ program, target: read })?.content).toContain("`Responses`: `204`, `400-499`"); + }); + + it("formats a default (`*`) status code", async () => { + const { program, read } = await Tester.compile(t.code` + @error model Error { message: string; } + @route("/pets") @get op ${t.op("read")}(): void | Error; + `); + + expect($onInfo({ program, target: read })?.content).toContain("`Responses`: `204`, `*`"); + }); + + // Guards against the hook being silently dropped: this exercises the real registration path + // (binder + `type-info-hook` opt-in from this package's own `tspconfig.yaml`) rather than + // calling `$onInfo` directly. + it("is registered by the compiler so `program.getTypeInfo` returns the route", async () => { + const { program, read } = await Tester.compile(t.code` + @route("/pets/{id}") @get op ${t.op("read")}(@path id: string): void; + `); + + expect(program.getTypeInfo(read)).toEqual({ + content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`", + }); + }); +}); diff --git a/packages/http/tspconfig.yaml b/packages/http/tspconfig.yaml new file mode 100644 index 00000000000..7d302310cc0 --- /dev/null +++ b/packages/http/tspconfig.yaml @@ -0,0 +1,7 @@ +# Opt this library into the experimental `type-info-hook` feature so that the `$onInfo` hook +# exported from `src/info.ts` is registered by the compiler. Per-package feature enablement is +# resolved from the owning package's config, so consumers do not need to enable it. +# NOTE: this file must stay listed in `package.json#files` or the opt-in is lost when published. +kind: project +features: + - type-info-hook diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index c0119ce3ebd..a777196504b 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -244,6 +244,10 @@ const sidebar: SidebarItem[] = [ "extending-typespec/basics", "extending-typespec/diagnostics", "extending-typespec/create-decorators", + { + slug: "extending-typespec/providing-info", + badge: { text: "experimental", variant: "caution" }, + }, "extending-typespec/linters", "extending-typespec/codefixes", "extending-typespec/testing", diff --git a/website/src/content/docs/docs/extending-typespec/providing-info.md b/website/src/content/docs/docs/extending-typespec/providing-info.md new file mode 100644 index 00000000000..05921004be4 --- /dev/null +++ b/website/src/content/docs/docs/extending-typespec/providing-info.md @@ -0,0 +1,104 @@ +--- +id: providing-info +title: Providing IDE & tooling info +--- + +Libraries can contribute extra, domain-specific information about types that is not part of +the core language — for example, `@typespec/http` surfaces the effective HTTP route of an +operation. This information is shown in IDE hover tooltips and can also be queried +programmatically by tooling (such as AI agents). + +:::caution +This is an experimental feature. A library must opt in by enabling the `type-info-hook` +[compiler feature](../handbook/configuration/configuration.mdx) in **its own** `tspconfig.yaml`: + +```yaml +kind: project +features: + - type-info-hook +``` + +The opt-in is scoped to the package declaring the hook — consumers of the library do not need +to enable anything to see the information. + +Because the opt-in is read from the published package, make sure `tspconfig.yaml` is actually +shipped — add it to `files` in your `package.json`, otherwise the hook is silently ignored +once the library is installed from a registry: + +```json +{ + "files": ["lib/**/*.tsp", "tspconfig.yaml", "dist/**"] +} +``` + +::: + +## The `$onInfo` hook + +A library provides this information by exporting an `$onInfo` function from its main entry +point. Use the `defineInfoHook` helper for typing. The hook receives an `InfoContext` (with +the current `program` and the `target` type) and returns a single `TypeInfo` object, or +`undefined` when it has nothing to contribute. + +```ts +import { defineInfoHook } from "@typespec/compiler"; +import { getHttpOperation } from "./operations.js"; + +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") { + return undefined; + } + const [operation] = getHttpOperation(program, target); + if (!operation) { + return undefined; + } + return { + content: `\`HTTP Route\`: \`${operation.verb.toUpperCase()} ${operation.uriTemplate}\``, + }; +}); +``` + +A `TypeInfo` currently has a single `content` field: the markdown content to show for this +piece of information. + +## How it is displayed + +In the IDE, the contributed content is appended after the type's signature and documentation, +separated by a horizontal rule so it is clearly distinguishable from the type's own doc +comment: + +```md +op read(id: string): void + +Reads a pet. + +--- + +`HTTP Route`: `GET /pets/{id}` + +`Responses`: `204` +``` + +## Important constraints + +Unlike [`$onValidate`](./diagnostics.md), the `$onInfo` hook: + +- **is never run during compilation.** It is invoked lazily and on demand. +- **must not mutate the type graph.** It should only read the program and answer questions + about it. + +Because it does not change the type graph, there are no ordering or race concerns between +libraries — the `content` from every library is simply concatenated. + +## Querying info programmatically + +Tooling can query all registered providers for a given type with `program.getTypeInfo`, +which merges the contributions from every library into a single `TypeInfo` (or `undefined` +when nothing is contributed): + +```ts +const info = program.getTypeInfo(type); +// { content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`" } +``` + +The language server uses this same API to enrich hover documentation.