Skip to content

Show full definition of model/interface when it's 'extends' or 'is' other model/interfaces. #7530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 6, 2025
Merged
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/full-def-hover-2025-5-3-14-54-27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Show the full definition of model and interface when it has 'extends' and 'is' relationship in the hover text
18 changes: 13 additions & 5 deletions packages/compiler/src/core/helpers/type-name-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { printIdentifier } from "./syntax-utils.js";
export interface TypeNameOptions {
namespaceFilter?: (ns: Namespace) => boolean;
printable?: boolean;
nameOnly?: boolean;
}

export function getTypeName(type: Type, options?: TypeNameOptions): string {
Expand Down Expand Up @@ -135,7 +136,7 @@ export function getNamespaceFullName(type: Namespace, options?: TypeNameOptions)
}

function getNamespacePrefix(type: Namespace | undefined, options?: TypeNameOptions) {
if (type === undefined || isStdNamespace(type)) {
if (type === undefined || isStdNamespace(type) || options?.nameOnly === true) {
return "";
}
const namespaceFullName = getNamespaceFullName(type, options);
Expand Down Expand Up @@ -212,6 +213,9 @@ function isInTypeSpecNamespace(type: Type & { namespace?: Namespace }): boolean
}

function getModelPropertyName(prop: ModelProperty, options: TypeNameOptions | undefined) {
if (options?.nameOnly === true) {
return prop.name;
}
const modelName = prop.model ? getModelName(prop.model, options) : undefined;

return `${modelName ?? "(anonymous model)"}.${prop.name}`;
Expand All @@ -234,10 +238,14 @@ function getOperationName(op: Operation, options: TypeNameOptions | undefined) {
const params = op.node.templateParameters.map((t) => getIdentifierName(t.id.sv, options));
opName += `<${params.join(", ")}>`;
}
const prefix = op.interface
? getInterfaceName(op.interface, options) + "."
: getNamespacePrefix(op.namespace, options);
return `${prefix}${opName}`;
if (options?.nameOnly === true) {
return opName;
} else {
const prefix = op.interface
? getInterfaceName(op.interface, options) + "."
: getNamespacePrefix(op.namespace, options);
return `${prefix}${opName}`;
}
}

function getIdentifierName(name: string, options: TypeNameOptions | undefined) {
Expand Down
44 changes: 37 additions & 7 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
WorkspaceEdit,
WorkspaceFoldersChangeEvent,
} from "vscode-languageserver/node.js";
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";
Expand Down Expand Up @@ -714,13 +715,42 @@ export function createServer(host: ServerHost): Server {
const sym =
id?.kind === SyntaxKind.Identifier ? program.checker.resolveRelatedSymbols(id) : undefined;

const markdown: MarkupContent = {
kind: MarkupKind.Markdown,
value: sym && sym.length > 0 ? getSymbolDetails(program, sym[0]) : "",
};
return {
contents: markdown,
};
if (!sym || sym.length === 0) {
return { contents: { kind: MarkupKind.Markdown, value: "" } };
} else {
// 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;
const sn = getSymNode(sym[0]);
if (sn.kind !== SyntaxKind.AliasStatement) {
const type = sym[0].type ?? program.checker.getTypeOrValueForNode(sn);
if (type && "kind" in type) {
const modelHasExtendOrIs: boolean =
type.kind === "Model" &&
(type.baseModel !== undefined ||
type.sourceModel !== undefined ||
type.sourceModels.length > 0);
const interfaceHasExtend: boolean =
type.kind === "Interface" && type.sourceInterfaces.length > 0;
includeExpandedDefinition = modelHasExtendOrIs || interfaceHasExtend;
}
}

const markdown: MarkupContent = {
kind: MarkupKind.Markdown,
value:
sym && sym.length > 0
? getSymbolDetails(program, sym[0], {
includeSignature: true,
includeParameterTags: true,
includeExpandedDefinition,
})
: "",
};
return {
contents: markdown,
};
}
}

async function getSignatureHelp(params: SignatureHelpParams): Promise<SignatureHelp | undefined> {
Expand Down
23 changes: 22 additions & 1 deletion packages/compiler/src/server/type-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import { isType } from "../core/type-utils.js";
import { DocContent, Node, Sym, SyntaxKind, TemplateDeclarationNode, Type } from "../core/types.js";
import { getSymbolSignature } from "./type-signature.js";

interface GetSymbolDetailsOptions {
includeSignature: boolean;
includeParameterTags: boolean;
/**
* Whether to include the final expended definition of the symbol
* For Model and Interface, it's body with expended members will be included. Otherwise, it will be the same as signature. (Support for other type may be added in the future as needed)
* This is useful for models and interfaces with complex 'extends' and 'is' relationship when user wants to know the final expended definition.
*/
includeExpandedDefinition?: boolean;
}

/**
* Get the detailed documentation for a symbol.
* @param program The program
Expand All @@ -14,9 +25,10 @@ import { getSymbolSignature } from "./type-signature.js";
export function getSymbolDetails(
program: Program,
symbol: Sym,
options = {
options: GetSymbolDetailsOptions = {
includeSignature: true,
includeParameterTags: true,
includeExpandedDefinition: false,
},
): string {
const lines = [];
Expand All @@ -43,6 +55,15 @@ export function getSymbolDetails(
}
}
}
if (options.includeExpandedDefinition) {
lines.push(`*Full Definition:*`);
lines.push(
getSymbolSignature(program, symbol, {
includeBody: true,
}),
);
}

return lines.join("\n\n");
}

Expand Down
82 changes: 68 additions & 14 deletions packages/compiler/src/server/type-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
Decorator,
EnumMember,
FunctionParameter,
Interface,
Model,
ModelProperty,
Operation,
StringTemplate,
Expand All @@ -18,40 +20,60 @@ import {
UnionVariant,
Value,
} from "../core/types.js";
import { walkPropertiesInherited } from "../index.js";

interface GetSymbolSignatureOptions {
/**
* Whether to include the body in the signature. Only support Model and Interface type now
*/
includeBody: boolean;
}

/** @internal */
export function getSymbolSignature(program: Program, sym: Sym): string {
export function getSymbolSignature(
program: Program,
sym: Sym,
options: GetSymbolSignatureOptions = {
includeBody: false,
},
): string {
const decl = getSymNode(sym);
switch (decl?.kind) {
case SyntaxKind.AliasStatement:
return fence(`alias ${getAliasSignature(decl)}`);
}
const entity = sym.type ?? program.checker.getTypeOrValueForNode(decl);
return getEntitySignature(sym, entity);
return getEntitySignature(sym, entity, options);
}

function getEntitySignature(sym: Sym, entity: Type | Value | null): string {
function getEntitySignature(
sym: Sym,
entity: Type | Value | null,
options: GetSymbolSignatureOptions,
): string {
if (entity === null) {
return "(error)";
}
if ("valueKind" in entity) {
return fence(`const ${sym.name}: ${getTypeName(entity.type)}`);
}

return getTypeSignature(entity);
return getTypeSignature(entity, options);
}

function getTypeSignature(type: Type): string {
function getTypeSignature(type: Type, options: GetSymbolSignatureOptions): string {
switch (type.kind) {
case "Scalar":
case "Enum":
case "Union":
case "Interface":
case "Model":
case "Namespace":
return fence(`${type.kind.toLowerCase()} ${getPrintableTypeName(type)}`);
case "Interface":
return fence(getInterfaceSignature(type, options.includeBody));
case "Model":
return fence(getModelSignature(type, options.includeBody));
case "ScalarConstructor":
return fence(`init ${getTypeSignature(type.scalar)}.${type.name}`);
return fence(`init ${getTypeSignature(type.scalar, options)}.${type.name}`);
case "Decorator":
return fence(getDecoratorSignature(type));
case "Operation":
Expand Down Expand Up @@ -80,7 +102,7 @@ function getTypeSignature(type: Type): string {
case "UnionVariant":
return `(union variant)\n${fence(getUnionVariantSignature(type))}`;
case "Tuple":
return `(tuple)\n[${fence(type.values.map(getTypeSignature).join(", "))}]`;
return `(tuple)\n[${fence(type.values.map((v) => getTypeSignature(v, options)).join(", "))}]`;
default:
const _assertNever: never = type;
compilerAssert(false, "Unexpected type kind");
Expand All @@ -94,9 +116,41 @@ function getDecoratorSignature(type: Decorator) {
return `dec ${ns}${name}(${parameters.join(", ")})`;
}

function getOperationSignature(type: Operation) {
const parameters = [...type.parameters.properties.values()].map(getModelPropertySignature);
return `op ${getTypeName(type)}(${parameters.join(", ")}): ${getPrintableTypeName(type.returnType)}`;
function getOperationSignature(type: Operation, includeQualifier: boolean = true) {
const parameters = [...type.parameters.properties.values()].map((p) =>
getModelPropertySignature(p, false /* includeQualifier */),
);
return `op ${getTypeName(type, {
nameOnly: !includeQualifier,
})}(${parameters.join(", ")}): ${getPrintableTypeName(type.returnType)}`;
}

function getInterfaceSignature(type: Interface, includeBody: boolean) {
if (includeBody) {
const INDENT = " ";
const opDesc = Array.from(type.operations).map(
([name, op]) => INDENT + getOperationSignature(op, false /* includeQualifier */) + ";",
);
return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)} {\n${opDesc.join("\n")}\n}`;
} else {
return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)}`;
}
}

/**
* All properties from 'extends' and 'is' will be included if includeBody is true.
*/
function getModelSignature(type: Model, includeBody: boolean) {
if (includeBody) {
const propDesc = [];
const INDENT = " ";
for (const prop of walkPropertiesInherited(type)) {
propDesc.push(INDENT + getModelPropertySignature(prop, false /*includeQualifier*/));
}
return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)}{\n${propDesc.map((d) => `${d};`).join("\n")}\n}`;
} else {
return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)}`;
}
}

function getFunctionParameterSignature(parameter: FunctionParameter) {
Expand All @@ -117,8 +171,8 @@ function getStringTemplateSignature(stringTemplate: StringTemplate) {
);
}

function getModelPropertySignature(property: ModelProperty) {
const ns = getQualifier(property.model);
function getModelPropertySignature(property: ModelProperty, includeQualifier: boolean = true) {
const ns = includeQualifier ? getQualifier(property.model) : "";
return `${ns}${printIdentifier(property.name, "allow-reserved")}: ${getPrintableTypeName(property.type)}`;
}

Expand Down
83 changes: 83 additions & 0 deletions packages/compiler/test/server/get-hover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,56 @@ describe("compiler: server: on hover", () => {
},
});
});

it("model with extends and is (full definition expected)", async () => {
const hover = await getHoverAtCursor(
`
namespace TestNs;

model Do┆g is Animal<string, DogProperties> {
barkVolume: int32;
}

model Animal<T, P> extends AnimalBase<P>{
name: string;
age: int16;
tTag: T;
}

model AnimalBase<P> {
id: string;
properties: P;
}


model DogProperties {
breed: string;
color: string;
}
`,
);
deepStrictEqual(hover, {
contents: {
kind: MarkupKind.Markdown,
value: `\`\`\`typespec
model TestNs.Dog
\`\`\`

*Full Definition:*

\`\`\`typespec
model TestNs.Dog{
name: string;
age: int16;
tTag: string;
barkVolume: int32;
id: string;
properties: TestNs.DogProperties;
}
\`\`\``,
},
});
});
});

describe("interface", () => {
Expand Down Expand Up @@ -449,6 +499,39 @@ describe("compiler: server: on hover", () => {
},
});
});

it("interface with extends", async () => {
const hover = await getHoverAtCursor(
`
namespace TestNs;

interface IActions{
fly(): void;
}

interface Bi┆rd extends IActions {
eat(): void;
}
`,
);
deepStrictEqual(hover, {
contents: {
kind: MarkupKind.Markdown,
value: `\`\`\`typespec
interface TestNs.Bird
\`\`\`

*Full Definition:*

\`\`\`typespec
interface TestNs.Bird {
op fly(): void;
op eat(): void;
}
\`\`\``,
},
});
});
});

describe("operation", () => {
Expand Down
Loading