Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/hover-diagnostic-docs-2026-6-9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

The language server now surfaces the extended documentation of diagnostics and linter rules when hovering over a reported error. If the diagnostic/rule provides `docs` (inline markdown or a `FileRef`), it is rendered in the hover, together with a link to the generated reference page when a documentation url is available.
20 changes: 20 additions & 0 deletions .chronus/changes/reference-docs-base-url-2026-6-9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add a `referenceDocs.baseUrl` field to the library definition passed to `createTypeSpecLibrary`. When set, the compiler auto-generates the `url` of each documented diagnostic and linter rule that does not specify one explicitly, so tooling (CLI and editor) can link to the generated reference pages without hardcoding a URL per rule/diagnostic:

- diagnostics -> `${baseUrl}/diagnostics/<code>`
- linter rules -> `${baseUrl}/rules/<name>`

```ts
export const $lib = createTypeSpecLibrary({
name: "@typespec/my-lib",
referenceDocs: { baseUrl: "https://typespec.io/docs/libraries/my-lib/reference" },
diagnostics: {
/* ... */
},
});
```
4 changes: 4 additions & 0 deletions packages/compiler/src/core/diagnostic-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
export function createDiagnosticCreator<T extends { [code: string]: DiagnosticMessages }>(
diagnostics: DiagnosticMap<T>,
libraryName?: string,
referenceDocsBaseUrl?: string,
): DiagnosticCreator<T> {
const errorMessage = libraryName
? `It must match one of the code defined in the library '${libraryName}'`
Expand Down Expand Up @@ -59,6 +60,9 @@ export function createDiagnosticCreator<T extends { [code: string]: DiagnosticMe
};
if (diagnosticDef.url) {
mutate(result).url = diagnosticDef.url;
} else if (diagnosticDef.docs && referenceDocsBaseUrl) {
mutate(result).url =
`${referenceDocsBaseUrl.replace(/\/$/, "")}/diagnostics/${String(diagnostic.code)}`;
}
if (diagnostic.codefixes) {
mutate(result).codefixes = diagnostic.codefixes;
Expand Down
6 changes: 5 additions & 1 deletion packages/compiler/src/core/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ export function createTypeSpecLibrary<
>(lib: Readonly<TypeSpecLibraryDef<T, E, State>>): TypeSpecLibrary<T, E, State> {
let emitterOptionValidator: JSONSchemaValidator;

const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator(lib.diagnostics, lib.name);
const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator(
lib.diagnostics,
lib.name,
lib.referenceDocs?.baseUrl,
);

function createStateSymbol(name: string): symbol {
return Symbol.for(`${lib.name}.${name}`);
Expand Down
7 changes: 6 additions & 1 deletion packages/compiler/src/core/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,14 @@ export interface LinterResult {
export function resolveLinterDefinition(
libName: string,
linter: LinterDefinition,
referenceDocsBaseUrl?: string,
): LinterResolvedDefinition {
const rules: LinterRule<string, any>[] = linter.rules.map((rule) => {
return { ...rule, id: `${libName}/${rule.name}` };
const resolved: LinterRule<string, any> = { ...rule, id: `${libName}/${rule.name}` };
if (resolved.url === undefined && resolved.docs && referenceDocsBaseUrl) {
resolved.url = `${referenceDocsBaseUrl.replace(/\/$/, "")}/rules/${rule.name}`;
}
return resolved;
});
if (linter.rules.length === 0 || (linter.ruleSets && "all" in linter.ruleSets)) {
return {
Expand Down
50 changes: 49 additions & 1 deletion packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
JsSourceFileNode,
LibraryInstance,
LibraryMetadata,
LinterDefinition,
LiteralType,
LocationContext,
LogSink,
Expand Down Expand Up @@ -134,12 +135,33 @@ export interface Program {
/** Return location context of the given source file. */
getSourceFileLocationContext(sourceFile: SourceFile): LocationContext;

/**
* Get information about a library loaded during this compilation, by its package name.
* Used by tooling (e.g. the language server) to resolve reference documentation.
* @internal
*/
getLoadedLibraryInfo(name: string): Promise<LoadedLibraryInfo | undefined>;

/**
* Project root. If a tsconfig was found/specified this is the directory for the tsconfig.json. Otherwise directory where the entrypoint is located.
*/
readonly projectRoot: string;
}

/**
* Information about a library loaded during compilation, used by tooling (e.g. the
* language server) to resolve reference documentation without re-resolving or reloading it.
* @internal
*/
export interface LoadedLibraryInfo {
/** Absolute path to the package root (the folder containing the library's package.json). */
readonly packageRoot: string;
/** The library's `$lib` export, if any. */
readonly $lib?: TypeSpecLibrary<any>;
/** The library's `$linter` export, if any. */
readonly $linter?: LinterDefinition;
}

interface EmitterRef {
emitFunction: EmitterFunc;
main: string;
Expand Down Expand Up @@ -271,6 +293,7 @@ async function createProgram(
/** @internal */
resolveTypeOrValueReference,
getSourceFileLocationContext,
getLoadedLibraryInfo,
projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""),
};

Expand Down Expand Up @@ -492,6 +515,25 @@ async function createProgram(
return locationContext;
}

async function getLoadedLibraryInfo(name: string): Promise<LoadedLibraryInfo | undefined> {
const ref = sourceResolution.loadedLibraries.get(name);
if (ref === undefined) {
return undefined;
}
// Load the library's default entrypoint (which exports `$lib` and `$linter`) the same way the
// compiler does. `loadJsFile` is cached, so for a library already loaded during this
// compilation this does not re-read or re-evaluate anything. Note the entry imported by
// `.tsp` files (the `typespec` condition) may only expose `$lib`, so we can't rely on the
// already-parsed `jsSourceFiles` to also carry `$linter`.
const [resolution] = await resolveEmitterModuleAndEntrypoint(basedir, name);
const esmExports = resolution?.entrypoint?.esmExports;
return {
packageRoot: ref.path,
$lib: esmExports?.$lib,
$linter: esmExports?.$linter,
};
}

async function loadEmitters(
basedir: string,
emitterNameOrPaths: string[],
Expand Down Expand Up @@ -548,7 +590,13 @@ async function createProgram(
...resolution,
metadata,
definition: libDefinition,
linter: linterDef && resolveLinterDefinition(libraryNameOrPath, linterDef),
linter:
linterDef &&
resolveLinterDefinition(
libraryNameOrPath,
linterDef,
libDefinition?.referenceDocs?.baseUrl,
),
};
}

Expand Down
12 changes: 12 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2498,6 +2498,18 @@ export interface TypeSpecLibraryDef<
/** Optional registration of capabilities the library/emitter provides */
readonly capabilities?: TypeSpecLibraryCapabilities;

/**
* Reference documentation configuration for this library.
* When set, the compiler auto-generates the `url` of each documented diagnostic and linter
* rule that does not specify one explicitly, pointing at the generated reference pages:
* - diagnostics -> `${baseUrl}/diagnostics/<code>`
* - linter rules -> `${baseUrl}/rules/<name>`
*/
readonly referenceDocs?: {
/** Base URL of the library's generated reference documentation (e.g. its `.../reference` page). */
readonly baseUrl: string;
};

/**
* Map of potential diagnostics that can be emitted in this library where the key is the diagnostic code.
*/
Expand Down
124 changes: 106 additions & 18 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
InitializeParams,
InitializeResult,
Location,
MarkupContent,
MarkupKind,
ParameterInformation,
PrepareRenameParams,
Expand Down Expand Up @@ -53,6 +52,7 @@ import { getSymNode } from "../core/binder.js";
import { CharCode } from "../core/charcode.js";
import { resolveCodeFix } from "../core/code-fixes.js";
import { compilerAssert, getSourceLocation } from "../core/diagnostics.js";
import { isFileRef } from "../core/file-ref.js";
import { formatTypeSpec } from "../core/formatter.js";
import { getEntityName, getTypeName } from "../core/helpers/type-name-utils.js";
import { builtInLinterRule_UnusedTemplateParameter } from "../core/linter-rules/unused-template-parameter.rule.js";
Expand Down Expand Up @@ -899,13 +899,14 @@ export function createServer(
return { contents: [] };
}

const id = getNodeAtPosition(script, document.offsetAt(params.position));
const offset = document.offsetAt(params.position);
const sections: string[] = [];

const id = getNodeAtPosition(script, offset);
const sym =
id?.kind === SyntaxKind.Identifier ? program.checker.resolveRelatedSymbols(id) : undefined;

if (!sym || sym.length === 0) {
return { contents: { kind: MarkupKind.Markdown, value: "" } };
} else {
if (sym && sym.length > 0) {
// Only show full definition if the symbol is a model or interface that has extends or is clauses.
// Avoid showing full definition in other cases which can be long and not useful
let includeExpandedDefinition = false;
Expand All @@ -924,21 +925,108 @@ export function createServer(
}
}

const markdown: MarkupContent = {
const details = await getSymbolDetails(program, sym[0], {
includeSignature: true,
includeParameterTags: true,
includeExpandedDefinition,
});
if (details) {
sections.push(details);
}
}

const diagnosticDoc = await getDiagnosticDocMarkdownAtPosition(
program,
script.file.path,
offset,
);
if (diagnosticDoc) {
sections.push(diagnosticDoc);
}

return {
contents: {
kind: MarkupKind.Markdown,
value:
sym && sym.length > 0
? await getSymbolDetails(program, sym[0], {
includeSignature: true,
includeParameterTags: true,
includeExpandedDefinition,
})
: "",
};
return {
contents: markdown,
};
value: sections.join("\n\n---\n\n"),
},
};
}

/**
* Build a markdown section with the extended documentation (and reference link) of any
* reported diagnostic that covers the given offset in the given file. Reuses the diagnostics
* from the last full compile (matched the same way the squiggles are), since the extended
* documentation cannot be attached to the LSP diagnostic itself.
*/
async function getDiagnosticDocMarkdownAtPosition(
program: Program,
filePath: string,
offset: number,
): Promise<string | undefined> {
const sections: string[] = [];
const seenCodes = new Set<string>();
for (const diag of currentDiagnosticIndex.values()) {
if (typeof diag.code !== "string" || seenCodes.has(diag.code)) {
continue;
}
const location = getSourceLocation(diag.target, { locateId: true });
if (!location?.file || location.file.path !== filePath) {
continue;
}
if (offset < location.pos || offset > location.end) {
continue;
}
seenCodes.add(diag.code);
const markdown = await resolveDiagnosticDocMarkdown(program, diag);
if (markdown) {
sections.push(markdown);
}
}
return sections.length > 0 ? sections.join("\n\n---\n\n") : undefined;
}

/** Resolve the extended docs + reference link markdown for a single reported diagnostic. */
async function resolveDiagnosticDocMarkdown(
program: Program,
diag: Diagnostic,
): Promise<string | undefined> {
const code = diag.code as string;
const segments = code.split("/");
const localCode = segments.pop();
const libName = segments.join("/");
const sections: string[] = [];

if (libName && localCode) {
// Reuse the library already loaded by the compiler instead of re-resolving/reloading it.
const library = await program.getLoadedLibraryInfo(libName);
const docs =
library?.$lib?.diagnostics?.[localCode]?.docs ??
library?.$linter?.rules?.find((rule) => rule.name === localCode)?.docs;
let docText: string | undefined;
if (typeof docs === "string") {
docText = docs;
} else if (isFileRef(docs) && library) {
try {
const file = await compilerHost.readFile(joinPaths(library.packageRoot, docs.path));
docText = file.text;
} catch (error) {
log({
level: "debug",
message: `Unable to read extended documentation for diagnostic '${code}'.`,
detail: error,
});
}
}
if (docText && docText.trim().length > 0) {
sections.push(docText.trim());
}
}

if (diag.url) {
sections.push(`[See documentation](${diag.url})`);
}

return sections.length > 0 ? sections.join("\n\n") : undefined;
}

async function getSignatureHelp(params: SignatureHelpParams): Promise<SignatureHelp | undefined> {
Expand Down
Loading