diff --git a/.chronus/changes/full-def-hover-2025-5-3-14-54-27.md b/.chronus/changes/full-def-hover-2025-5-3-14-54-27.md new file mode 100644 index 00000000000..8eb2f9bebaf --- /dev/null +++ b/.chronus/changes/full-def-hover-2025-5-3-14-54-27.md @@ -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 \ No newline at end of file diff --git a/packages/compiler/src/core/helpers/type-name-utils.ts b/packages/compiler/src/core/helpers/type-name-utils.ts index af9e970fd85..8347eb789f1 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -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 { @@ -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); @@ -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}`; @@ -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) { diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 1305af6b8b3..68b9b7952cf 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -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"; @@ -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 { diff --git a/packages/compiler/src/server/type-details.ts b/packages/compiler/src/server/type-details.ts index 1fc59528ecc..87ed80aa02b 100644 --- a/packages/compiler/src/server/type-details.ts +++ b/packages/compiler/src/server/type-details.ts @@ -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 @@ -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 = []; @@ -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"); } diff --git a/packages/compiler/src/server/type-signature.ts b/packages/compiler/src/server/type-signature.ts index 352f0a7ce70..f9d181c6686 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -9,6 +9,8 @@ import { Decorator, EnumMember, FunctionParameter, + Interface, + Model, ModelProperty, Operation, StringTemplate, @@ -18,19 +20,37 @@ 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)"; } @@ -38,20 +58,22 @@ function getEntitySignature(sym: Sym, entity: Type | Value | null): string { 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": @@ -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"); @@ -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) { @@ -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)}`; } diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 62dd8c95ab4..a2fea2025f9 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -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 { + barkVolume: int32; + } + + model Animal extends AnimalBase

{ + name: string; + age: int16; + tTag: T; + } + + model AnimalBase

{ + 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", () => { @@ -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", () => {