From fe7fed226014de6b90cd4a5fa2bf04aee9df163e Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:13:48 +0900 Subject: [PATCH 01/12] - parse inline block comment in parser - give meaning to inline block comment in response description --- packages/compiler/src/core/parser.ts | 51 ++- packages/compiler/test/parser.test.ts | 76 ++++ packages/http/src/responses.ts | 239 ++++++++++++- .../http/test/response-descriptions.test.ts | 337 ++++++++++++++++++ 4 files changed, 692 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index e977aa02fe2..ac03dafb3db 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -417,6 +417,19 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return { pos, docs, directives, decorators }; } + function parseInlineDocComments(): { + pos: number; + docs: DocNode[]; + } { + const docs: DocNode[] = []; + const [pos, addedDocs] = parseDocList(); + for (const doc of addedDocs) { + docs.push(doc); + } + + return { pos, docs }; + } + function parseTypeSpecScriptItemList(): Statement[] { const stmts: Statement[] = []; let seenBlocklessNs = false; @@ -841,7 +854,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (token === Token.OpenParen) { const parameters = parseOperationParameters(); parseExpected(Token.Colon); - const returnType = parseExpression(); + // try to parse inline docs + const { docs } = parseInlineDocComments(); + const returnType = parseExpression(docs); signature = { kind: SyntaxKind.OperationSignatureDeclaration, @@ -1228,7 +1243,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); parseExpected(Token.Equals); - const value = parseExpression(); + // try to parse inline docs + const { docs } = parseInlineDocComments(); + const value = parseExpression(docs); parseExpected(Token.Semicolon); return { kind: SyntaxKind.AliasStatement, @@ -1263,23 +1280,39 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return undefined; } - function parseExpression(): Expression { - return parseUnionExpressionOrHigher(); + function parseExpression(docs: DocNode[] = []): Expression { + return parseUnionExpressionOrHigher(docs); } - function parseUnionExpressionOrHigher(): Expression { - const pos = tokenPos(); + function parseUnionExpressionOrHigher(exprDocs: DocNode[]): Expression { parseOptional(Token.Bar); + // try to parse inline docs + const { docs: rideSideDocs, pos } = parseInlineDocComments(); + // doc comments right side of `|` take precedence over left side + // e.g. + // op foo: /** exprDocs */ | /** rideSideDocs */ MyModel; + const docs = rideSideDocs.length > 0 ? rideSideDocs : exprDocs; const node: Expression = parseIntersectionExpressionOrHigher(); + const expr = { + ...node, + docs, + pos, + }; if (token() !== Token.Bar) { - return node; + return expr; } - const options = [node]; + const options = [expr]; while (parseOptional(Token.Bar)) { + // try to parse inline docs + const { docs, pos } = parseInlineDocComments(); const expr = parseIntersectionExpressionOrHigher(); - options.push(expr); + options.push({ + ...expr, + docs, + pos, + }); } return { diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 1e8d360c19d..0792b422f29 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -1091,6 +1091,82 @@ describe("compiler: parser", () => { strictEqual(comments[0].parsedAsDocs, true); }); + it("mark used block comment with parsedAsDocs at operation", () => { + const script = parse( + ` + op foo(): /** One-liner */ | + { bar: string }; + `, + { docs: true, comments: true }, + ); + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + }); + + describe("mark used block comment with parsedAsDocs at union", () => { + parseEach( + [ + [ + ` + op foo(): | /** First-union */ + { bar: string } | /** Second-union */ { baz: string }; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + alias MyResponse = /** First-union */ { bar: string } | /** Second-union */ { baz: string }; + op foo(): MyResponse; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + model Second { baz: string }; + union MyResponse { /** First-union */ { bar: string }; /** Second-union */ Second; }; + op foo(): MyResponse; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + /** This is Second model */ + model Second { baz: string }; + union MyResponse { /** First-union */ { bar: string }; /** Second-union */ Second; }; + op foo(): /** Override MyResponse union */ MyResponse | /** Override third-union */ { qux: string }; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + ], + { docs: true, comments: true }, + ); + }); + it("other comments are not marked with parsedAsDocs", () => { const script = parse( ` diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index c6b51553254..9d946480e9a 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -1,7 +1,9 @@ import { + compilerAssert, createDiagnosticCollector, Diagnostic, DiagnosticCollector, + DocContent, getDoc, getErrorsDoc, getReturnsDoc, @@ -14,6 +16,14 @@ import { Program, Type, } from "@typespec/compiler"; +import { + IntersectionExpressionNode, + Node, + SyntaxKind, + TypeReferenceNode, + UnionExpressionNode, + UnionStatementNode, +} from "@typespec/compiler/ast"; import { $ } from "@typespec/compiler/typekit"; import { getStatusCodeDescription, getStatusCodesWithDiagnostics } from "./decorators.js"; import { HttpProperty } from "./http-property.js"; @@ -33,6 +43,8 @@ export function getResponsesForOperation( const responseType = operation.returnType; const responses = new ResponseIndex(); const tk = $(program); + const inlineDocNodeTreeMap = generateInlineDocNodeTreeMap(program, operation); + if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) { // Check if the union itself has a @doc to use as the response description const unionDescription = getDoc(program, responseType); @@ -47,11 +59,20 @@ export function getResponsesForOperation( operation, responses, option.type, + inlineDocNodeTreeMap, unionDescription, ); } } else { - processResponseType(program, diagnostics, operation, responses, responseType, undefined); + processResponseType( + program, + diagnostics, + operation, + responses, + responseType, + inlineDocNodeTreeMap, + undefined, + ); } return diagnostics.wrap(responses.values()); @@ -90,6 +111,7 @@ function processResponseType( operation: Operation, responses: ResponseIndex, responseType: Type, + inlineDocNodeTreeMap: InlineDocNodeTreeMap, parentDescription?: string, ) { const tk = $(program); @@ -105,12 +127,14 @@ function processResponseType( if (isNullType(option.type)) { continue; } + processResponseType( program, diagnostics, operation, responses, option.type, + inlineDocNodeTreeMap, unionDescription, ); } @@ -157,6 +181,7 @@ function processResponseType( responseType, statusCode, metadata, + inlineDocNodeTreeMap, parentDescription, ), responses: [], @@ -249,9 +274,19 @@ function getResponseDescription( responseType: Type, statusCode: HttpStatusCodes[number], metadata: HttpProperty[], + inlineDocNodeTreeMap: InlineDocNodeTreeMap, parentDescription?: string, ): string | undefined { - // If a parent union provided a description, use that first + // If an inline doc comment provided, use that first + const inlineDescription = getNearestInlineDescriptionFromOperationReturnTypeNode( + inlineDocNodeTreeMap, + responseType.node, + ); + if (inlineDescription) { + return inlineDescription; + } + + // If a parent union provided a description, use that second if (parentDescription) { return parentDescription; } @@ -279,3 +314,203 @@ function getResponseDescription( return getStatusCodeDescription(statusCode); } + +/** + * Maps nodes to their semantic parents for tracling inline doc comment inheritance. + * It close to the concept of Concrete Syntax Tree (CST). + * + * Unlike AST parent relationships which reflect syntax structure, this map tracks + * semantic relationships after type resolution to enable proper doc comment + * inheritance through aliases, unions, and other TypeSpec constructs. + * + * The key is a {@link Node}, and the value is its semantic parent {@link Node} or `null` if none exists. + * It means that is a root {@link Node} if the value is `null`. + */ +interface InlineDocNodeTreeMap extends WeakMap {} + +/** + * Collect inline doc comments from response type node by traversing the tree. + * This operation should do only once per operation due to it can traverse + * by the given {@link Operation.returnType}'s node. + */ +function generateInlineDocNodeTreeMap( + program: Program, + operation: Operation, +): InlineDocNodeTreeMap { + let node = operation.returnType.node; + // if the return type node of operation is a single type reference, which doesn't appear in AST + // about operation.returnType.node + // so we need to get the actual type reference node from operation signature's return type + if ( + operation.node?.kind === SyntaxKind.OperationStatement && + operation.node.signature.kind === SyntaxKind.OperationSignatureDeclaration + ) { + node = operation.node.signature.returnType; + } + + const map: InlineDocNodeTreeMap = new WeakMap(); + if (node?.kind === SyntaxKind.UnionExpression) { + traverseUnionExpression(program, map, node, null); + } + if (node?.kind === SyntaxKind.TypeReference) { + traverseTypeReference(program, map, node, null); + } + return map; +} + +/** + * This function traverse up the tree from the given resolved response type node + * which is the bottom of the traversal. + * Return the nearest inline description from the {@link Operation.returnType}'s node. + */ +function getNearestInlineDescriptionFromOperationReturnTypeNode( + map: InlineDocNodeTreeMap, + node?: Node, + nearestNodeHasDoc?: Node, +): string | null { + // this branch couldn't happen normally + if (!node) return null; + const parentNode = map.get(node); + const nodeText = getLastDocText(node); + // if no parent, stop traversing and return the description + if (!parentNode) { + // if root node has no description, return the description + // from nearest node which could have inline doc comment + if (!nodeText && nearestNodeHasDoc) { + return getLastDocText(nearestNodeHasDoc); + } + // no parent and no nearest node with doc, return the description + // from current node which could have inline doc comment + return nodeText; + } + + const parentNodeText = getLastDocText(parentNode); + if (map.has(parentNode)) { + // if parent has no description and current node has description, + // keep current node as nearestNodeHasDoc which could have inline doc comment + if (!parentNodeText && nodeText) { + return getNearestInlineDescriptionFromOperationReturnTypeNode(map, parentNode, node); + } + // keep nearestNodeHasDoc as nearest node which could have inline doc comment + return getNearestInlineDescriptionFromOperationReturnTypeNode( + map, + parentNode, + nearestNodeHasDoc, + ); + } + return null; +} + +function traverseTypeReference( + program: Program, + map: InlineDocNodeTreeMap, + node: TypeReferenceNode, + parentNode: Node | null, +): void { + map.set(node, parentNode); + const type = program.checker.getTypeForNode(node); + const ancestorNode = node; + + if (type.node) { + const childNode = type.node; + const kind = childNode.kind; + if (kind === SyntaxKind.UnionExpression) { + traverseUnionExpression(program, map, childNode, ancestorNode); + } + if (kind === SyntaxKind.UnionStatement) { + traverseUnionStatement(program, map, childNode, ancestorNode); + } + if (kind === SyntaxKind.IntersectionExpression) { + traverseIntersectionExpression(program, map, childNode, ancestorNode); + } + if (kind === SyntaxKind.ModelStatement || kind === SyntaxKind.ModelExpression) { + map.set(childNode, ancestorNode); + } + } +} + +function traverseUnionExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: UnionExpressionNode, + parentNode: Node | null, +): void { + for (const option of node.options) { + if (option.kind === SyntaxKind.TypeReference) { + traverseTypeReference(program, map, option, parentNode); + } + if (option.kind === SyntaxKind.IntersectionExpression) { + traverseIntersectionExpression(program, map, option, parentNode); + } + if (option.kind === SyntaxKind.ModelExpression) { + map.set(option, parentNode); + } + } +} + +function traverseUnionStatement( + program: Program, + map: InlineDocNodeTreeMap, + node: UnionStatementNode, + parentNode: Node | null, +) { + for (const option of node.options) { + map.set(option, parentNode); + + if (option.kind === SyntaxKind.UnionVariant) { + if (option.value.kind === SyntaxKind.TypeReference) { + traverseTypeReference(program, map, option.value, parentNode); + } + if (option.value.kind === SyntaxKind.ModelExpression) { + map.set(option.value, parentNode); + } + } + } +} + +function traverseIntersectionExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: IntersectionExpressionNode, + parentNode: Node | null, +): void { + map.set(node, parentNode); + + for (const option of node.options) { + if (option.kind === SyntaxKind.UnionExpression) { + traverseUnionExpression(program, map, option, parentNode); + } + if (option.kind === SyntaxKind.TypeReference) { + traverseTypeReference(program, map, option, parentNode); + } + } +} + +function getLastDocText(node: Node): string | null { + // the doc node isn't an inline doc comment when it belongs to a model statement + // this condition should be an allowlist for nodes which can have inline doc comments + const isAllowedNodeKind = + node.kind !== SyntaxKind.TypeReference && + node.kind !== SyntaxKind.ModelExpression && + node.kind !== SyntaxKind.IntersectionExpression; + if (isAllowedNodeKind) return null; + const docs = node.docs; + if (!docs || docs.length === 0) return null; + const lastDoc = docs[docs.length - 1]; + return getDocContent(lastDoc.content); +} + +/** + * same as {@link file://./../../compiler/src/core/checker.ts} + */ +function getDocContent(content: readonly DocContent[]) { + const docs = []; + for (const node of content) { + compilerAssert( + node.kind === SyntaxKind.DocText, + "No other doc content node kinds exist yet. Update this code appropriately when more are added.", + ); + docs.push(node.text); + } + return docs.join(""); +} diff --git a/packages/http/test/response-descriptions.test.ts b/packages/http/test/response-descriptions.test.ts index e64fe46cc8a..c25385371d8 100644 --- a/packages/http/test/response-descriptions.test.ts +++ b/packages/http/test/response-descriptions.test.ts @@ -80,4 +80,341 @@ describe("http: response descriptions", () => { ); strictEqual(op.responses[0].description, "Explicit doc"); }); + + it("inline doc comments for an operation returnType", async () => { + const op = await getHttpOp( + ` + op read(): + /** 🍋 */ + { @statusCode _: 200, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🍋"); + }); + + it("inline doc comments for an operation returnType with union expression", async () => { + const op = await getHttpOp( + ` + op read(): | + /** 🍌 */ + { @statusCode _: 200, content: string } | + /** 🍎 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🍌"); + strictEqual(op.responses[1].description, "🍎"); + strictEqual( + op.responses[2].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + }); + + it("inline doc comments for an operation returnType with union alias", async () => { + const op = await getHttpOp( + ` + /** 🥝 */ + model MySuccess200 { @statusCode _: 200, content: string }; + alias MyResponse = + /** 🌰 */ + MySuccess200 | + /** 🍇 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string }; + op read(): MyResponse; + `, + ); + strictEqual(op.responses[0].description, "🌰"); + strictEqual(op.responses[1].description, "🍇"); + strictEqual( + op.responses[2].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + }); + + it("inline doc comments and @doc and @returnsDoc for an operation returnType with union declaration", async () => { + const op = await getHttpOp( + ` + @doc("🍉") + union MyResponse { + /** 🥭 */ + { @statusCode _: 200, content: string }; + /** 🍍 */ + { @statusCode _: 201, content: string }; + { @statusCode _: 202, content: string }; + }; + @returnsDoc("✅") + op read(): MyResponse | + { @statusCode _: 400, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🥭"); + strictEqual(op.responses[1].description, "🍍"); + strictEqual(op.responses[2].description, "🍉"); + strictEqual(op.responses[3].description, "✅"); + }); + + it("inline doc comments for an operation returnType with union declaration ovrrided", async () => { + const op = await getHttpOp( + ` + alias My400 = /** 🍎 */ { @statusCode _: 400, content: string }; + union My401 { + /** 🍐 */ + { @statusCode _: 401, content: string }; + }; + union My400_401 { + /** 🥭 */ + My400; + /** 🍍 */ + My401; + }; + union MyError { + /** 🍊 */ + My400_401; + } + @doc("🍉") + union MyResponse { + /** 🍌 */ + MyError; + { @statusCode _: 403, content: string }; + }; + op read(): + { @statusCode _: 200, content: string } | + /** 🍏 */ + MyResponse; + `, + ); + strictEqual(op.responses[0].description, "The request has succeeded."); + strictEqual(op.responses[1].description, "🍏"); + strictEqual(op.responses[2].description, "🍏"); + strictEqual(op.responses[3].description, "🍏"); + }); + + it("complex inline doc comments for an operation returnType with union alias", async () => { + const op = await getHttpOp( + ` + /** 🥝 */ + union My400_401 { + /** 🌰 */ + { @statusCode _: 400, content: string }; + /** 🍎 */ + { @statusCode _: 401, content: string }; + }; + alias _MyResponse = + /** 🍇 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string } | + My400_401; + alias MyResponse = _MyResponse | { @statusCode _: 200, content: string }; + op read(): MyResponse; + `, + ); + strictEqual(op.responses[0].description, "🍇"); + strictEqual( + op.responses[1].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + strictEqual(op.responses[2].description, "🌰"); + strictEqual(op.responses[3].description, "🍎"); + }); + + it("inline doc comments deeply nested aliases with mixed doc patterns", async () => { + const op = await getHttpOp( + ` + /** Base error doc */ + model BaseError { @statusCode _: 500, message: string } + + /** Custom 404 */ + alias NotFound = { @statusCode _: 404, error: string }; + + alias Level1 = + /** Level1 doc */ + NotFound | BaseError; + + alias Level2 = + /** Level2 doc */ + Level1 | { @statusCode _: 403, reason: string }; + + /** Top level union doc */ + union TopUnion { + /** Variant A doc */ + Level2; + /** Variant B doc */ + { @statusCode _: 429, retryAfter: string }; + } + + op test(): + /** Inline success doc */ + { @statusCode _: 200, data: string } | + TopUnion; + `, + ); + + strictEqual(op.responses[0].description, "Inline success doc"); + strictEqual(op.responses[1].description, "Variant A doc"); + strictEqual(op.responses[2].description, "Variant A doc"); + strictEqual(op.responses[3].description, "Variant A doc"); + strictEqual(op.responses[4].description, "Variant B doc"); + }); + + it("inline doc comments circular alias references with docs", async () => { + const op = await getHttpOp( + ` + alias ErrorA = + /** Error A doc */ + { @statusCode _: 400, type: "A" } | ErrorB; + + alias ErrorB = + /** Error B doc */ + { @statusCode _: 401, type: "B" } | ErrorC; + + alias ErrorC = + /** Error C doc */ + { @statusCode _: 402, type: "C" }; + + op test(): + { @statusCode _: 200, success: true } | ErrorA; + `, + ); + + strictEqual(op.responses[0].description, "The request has succeeded."); + strictEqual(op.responses[1].description, "Error A doc"); + strictEqual(op.responses[2].description, "Error B doc"); + strictEqual(op.responses[3].description, "Error C doc"); + }); + + it("multiple inline doc comments at same level", async () => { + const op = await getHttpOp( + ` + /** First doc */ + /** Second doc */ + /** Third doc */ + model Response200 { @statusCode _: 200, data: string } + + op test(): + /** Inline first */ + /** Inline second */ + Response200 | + /** Error doc 1 */ + /** Error doc 2 */ + { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Inline second"); + strictEqual(op.responses[1].description, "Error doc 2"); + }); + + it("inline doc comments with intersections", async () => { + const op = await getHttpOp( + ` + /** Base response doc */ + model SuccessResponse200 { @statusCode _: 200 } + model SuccessResponse201 { @statusCode _: 201 } + + /** Data mixin doc */ + model DataMixin { data: string } + + alias IntersectionResponse = + /** Intersection doc */ + SuccessResponse200 & DataMixin; + + op test(): + IntersectionResponse | + /** Intersection inline doc */ + (SuccessResponse201 & DataMixin) | + { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Intersection doc"); + strictEqual(op.responses[1].description, "Intersection inline doc"); + strictEqual( + op.responses[2].description, + "The server could not understand the request due to invalid syntax.", + ); + }); + + it("inline doc comments extreme nesting stress test", async () => { + const op = await getHttpOp( + ` + /** L0 doc */ + alias L0 = { @statusCode _: 200, l0: true }; + + /** L1 doc */ + alias L1 = L0 | { @statusCode _: 201, l1: true }; + + /** L2 doc */ + alias L2 = L1 | { @statusCode _: 202, l2: true }; + + /** L3 doc */ + alias L3 = L2 | { @statusCode _: 203, l3: true }; + + /** L4 doc */ + alias L4 = L3 | { @statusCode _: 204, l4: true }; + + /** L5 doc */ + alias L5 = L4 | { @statusCode _: 205, l5: true }; + + op test(): + /** Final doc */ + L5 | { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Final doc"); + strictEqual(op.responses[1].description, "Final doc"); + strictEqual(op.responses[2].description, "Final doc"); + strictEqual(op.responses[3].description, "Final doc"); + strictEqual(op.responses[4].description, "Final doc"); + strictEqual(op.responses[5].description, "Final doc"); + strictEqual( + op.responses[6].description, + "The server could not understand the request due to invalid syntax.", + ); + }); + + it("ordering check", async () => { + const docs = [ + "/** 1 */", + '@@doc(ReadRespose, "2");', + '@doc("3")', + "/** 4 */", + '@doc("5")', + "/** 6 */", + '@returnsDoc("7")', + ]; + for (const [index, doc] of docs.entries()) { + /** + * This test should keep in sync with the example in the documentation + * {@link file://./../../../website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md#description-ordering} + */ + const code = ` + ${docs[5]} + ${docs[4]} + model SuccessResponse { + @statusCode _: 200; + content: string; + } + + ${docs[3]} + ${docs[2]} + union ReadRespose { + SuccessResponse; + } + + ${docs[1]} + + ${docs[6]} + op read(): ${docs[0]} + ReadRespose; + `; + const op = await getHttpOp(code); + const description = String(index + 1); + strictEqual(op.responses[0].description, description); + // comment out current doc to test the next one + docs[index] = `// ${doc}`; + } + }); }); From 7afbdd55433dbceb2be5e8530a90a30fda19454b Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Mon, 22 Dec 2025 00:22:04 +0900 Subject: [PATCH 02/12] formatter --- .../compiler/src/formatter/print/printer.ts | 20 ++++- .../compiler/test/formatter/formatter.test.ts | 79 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index fe9834ce1c4..8cdcbda79c6 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -22,6 +22,7 @@ import { EnumMemberNode, EnumSpreadMemberNode, EnumStatementNode, + Expression, FunctionDeclarationStatementNode, FunctionParameterNode, IdentifierNode, @@ -1267,12 +1268,29 @@ export function printNamespaceStatement( return [decorators, `namespace `, join(".", names), suffix]; } +function shouldWrapOperationSignatureDeclarationInNewLines(returnType: Expression) { + if ( + returnType.kind === SyntaxKind.TypeReference || + returnType.kind === SyntaxKind.ModelExpression + ) { + return returnType.docs && returnType.docs.length > 0; + } + return false; +} + export function printOperationSignatureDeclaration( path: AstPath, options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - return ["(", path.call(print, "parameters"), "): ", path.call(print, "returnType")]; + const node = path.node; + let closeParenColon = "): "; + // if inline doc comments on return type, move to new line + const shouldAddNewLine = shouldWrapOperationSignatureDeclarationInNewLines(node.returnType); + if (shouldAddNewLine) { + closeParenColon = "):\n"; + } + return ["(", path.call(print, "parameters"), closeParenColon, path.call(print, "returnType")]; } export function printOperationSignatureReference( diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 71434be8249..829404fcfda 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -766,6 +766,85 @@ op foo( }); }); }); + + describe("inline doc comments", () => { + it("single inline expression", async () => { + await assertFormat({ + code: ` +op foo(): /** inline doc comment */ { @statusCode _: 200; content: string; };`, + expected: ` +op foo(): +/** inline doc comment */ +{ + @statusCode _: 200; + content: string; +};`, + }); + }); + + it("single model statement", async () => { + await assertFormat({ + code: ` +model SuccessResponse { + @statusCode _: 200; + content: string; +} +op foo(): /** inline doc comment */ +SuccessResponse;`, + expected: ` +model SuccessResponse { + @statusCode _: 200; + content: string; +} +op foo(): +/** inline doc comment */ +SuccessResponse;`, + }); + }); + + it("union expression", async () => { + await assertFormat({ + code: ` +model Success201 { @statusCode _: 201; content: string; } +model Success204 { @statusCode _: 204; content: string; } +model Error400 { @statusCode _: 400; content: string; } + +op foo(): +/** 200 */ +{ @statusCode _: 200; content: string; } | +/** + * 201 + */ +Success201 | Success204 | Error400;`, + expected: ` +model Success201 { + @statusCode _: 201; + content: string; +} +model Success204 { + @statusCode _: 204; + content: string; +} +model Error400 { + @statusCode _: 400; + content: string; +} + +op foo(): + | /** 200 */ + { + @statusCode _: 200; + content: string; + } + | /** + * 201 + */ + Success201 + | Success204 + | Error400;`, + }); + }); + }); }); describe("scalar", () => { From a70698d9f4b990b1f9d63b7d547ac33d6ba66a3d Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:13:41 +0900 Subject: [PATCH 03/12] openapi doc --- .../typespec-for-openapi-dev.md | 120 ++++++++++++++++-- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md index ca02a4d3ac5..1b8891a50e3 100644 --- a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md +++ b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md @@ -456,14 +456,14 @@ elements common to both. The fields in an OpenAPI response object are specified with the following TypeSpec constructs: -| OpenAPI `response` field | TypeSpec construct | Notes | -| ------------------------ | --------------------------------------------------- | --------------------------------------------------- | -| `description` | `@doc` decorator | | -| `headers` | fields in the return type with `@header` decorator | Required or optional based on optionality of field. | -| `schema` (OAS2) | return type or type of `@body`` property | | -| `content` (OAS3) | return type or type of `@body`` property | | -| `examples` (OAS3) | `@opExample` to describe return types of operations | Supported on an operation. | -| `links` (OAS3) | | Not currently supported. | +| OpenAPI `response` field | TypeSpec construct | Notes | +| ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------- | +| `description` | `/** */` or `@doc`, `@@doc`, `@returnsDoc` and `@errorsDoc` decorator | see [Description ordering](#description-ordering) | +| `headers` | fields in the return type with `@header` decorator | Required or optional based on optionality of field. | +| `schema` (OAS2) | return type or type of `@body`` property | | +| `content` (OAS3) | return type or type of `@body`` property | | +| `examples` (OAS3) | `@opExample` to describe return types of operations | Supported on an operation. | +| `links` (OAS3) | | Not currently supported. | ```typespec @get op read(@path id: string): { @@ -495,6 +495,110 @@ namespace ResponseContent { } ``` +### Description ordering + +In TypeSpec, you can use various ways to configure the `description`, which is one of the OpenAPI `response` fields. +Here is an example of a documentation feature ordering, which will be the real output of the `description` field in the `response` for an operation. + +1. An [Inline doc comment](#inline-doc-comment) +1. A `@@doc` [Augment decorators](../language-basics/decorators.md#augmenting-decorators) +1. A [`@doc`](../standard-library/built-in-decorators.md#@doc) decorator on [Union](../language-basics/unions.md) +1. A [`/** */`](../language-basics/documentation.md#comments) doc comment on [Unions](../language-basics/unions.md) +1. A [`@doc`](../standard-library/built-in-decorators.md#@doc) decorator on [Models](../language-basics/models.md) +1. A [`/** */`](../language-basics/documentation.md#comments) doc comment on [Models](../language-basics/models.md) +1. A [`@returnsDoc`](../standard-library/built-in-decorators.md#@returnsDoc) on [Operations](../language-basics/operations.md) + + +```typespec +/** 6 */ +@doc("5") +model SuccessResponse { + @statusCode _: 200; + content: string; +} + +/** 4 */ +@doc("3") +union ReadRespose { + SuccessResponse, +} + +@@doc(ReadRespose, "2"); + +@returnsDoc("7") +op read(): +/** 1 */ +ReadRespose; +``` + +results in + +```yaml title=openapi.yaml +# ... + responses: + '200': + description: '1' + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' +components: + schemas: + SuccessResponse: + type: object + required: + - content + properties: + content: + type: string + description: '5' +``` + +### Inline doc comment + +Inline [`/** */`](../language-basics/documentation.md#comments) doc comments provide more flexibility to configure the `description` field in the `response`. + + +```typespec +op getUser(@path id: string): +| /** User details retrieved successfully. */ + { + @statusCode _: 200; + @body body: { + id: string; + name: string; + } + } +| /** User not found. */ + { + @statusCode _: 404; + }; +``` + +results in + +```yaml title=openapi.yaml +# ... + responses: + '200': + description: User details retrieved successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + '404': + description: User not found. +components: {} +``` + ## Schema Object OpenAPI schemas are represented in TypeSpec by [models](https://typespec.io/docs/language-basics/models/). From bf048a4b44afb69457bff52775b328302e82b2a5 Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:16:27 +0900 Subject: [PATCH 04/12] add change --- .../changes/feat-response-doc-2025-11-22-13-13-51.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .chronus/changes/feat-response-doc-2025-11-22-13-13-51.md diff --git a/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md new file mode 100644 index 00000000000..5a9bd1f2955 --- /dev/null +++ b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md @@ -0,0 +1,11 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" + - "@typespec/http" +--- + +Parse inline doc comments. +Associate parsed inline doc comments with response descriptions. +Change formatter behavior to improve the readability of inline doc comments. +Documentation for website about this feature and ordering of what doc will be adopted. From b7750dd51c13ce5b106c8033883790f3809754a6 Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:42:42 +0900 Subject: [PATCH 05/12] nit --- packages/http/src/responses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 9d946480e9a..0df0a84b0d5 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -501,7 +501,7 @@ function getLastDocText(node: Node): string | null { } /** - * same as {@link file://./../../compiler/src/core/checker.ts} + * same as {@link file://./../../compiler/src/core/checker.ts getDocContent} */ function getDocContent(content: readonly DocContent[]) { const docs = []; From d7b794b749c89ec6a0dbb7e14a6f309fda94dfdb Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:54:51 +0900 Subject: [PATCH 06/12] support Array and Scalar --- packages/http/src/responses.ts | 39 +++++++++++++++- .../http/test/response-descriptions.test.ts | 44 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 0df0a84b0d5..e80568a300c 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -17,6 +17,7 @@ import { Type, } from "@typespec/compiler"; import { + ArrayExpressionNode, IntersectionExpressionNode, Node, SyntaxKind, @@ -352,6 +353,9 @@ function generateInlineDocNodeTreeMap( if (node?.kind === SyntaxKind.UnionExpression) { traverseUnionExpression(program, map, node, null); } + if (node?.kind === SyntaxKind.ArrayExpression) { + traverseArrayExpression(program, map, node, null); + } if (node?.kind === SyntaxKind.TypeReference) { traverseTypeReference(program, map, node, null); } @@ -423,7 +427,11 @@ function traverseTypeReference( if (kind === SyntaxKind.IntersectionExpression) { traverseIntersectionExpression(program, map, childNode, ancestorNode); } - if (kind === SyntaxKind.ModelStatement || kind === SyntaxKind.ModelExpression) { + if ( + kind === SyntaxKind.ModelStatement || + kind === SyntaxKind.ModelExpression || + kind === SyntaxKind.ScalarStatement + ) { map.set(childNode, ancestorNode); } } @@ -439,6 +447,9 @@ function traverseUnionExpression( if (option.kind === SyntaxKind.TypeReference) { traverseTypeReference(program, map, option, parentNode); } + if (option.kind === SyntaxKind.ArrayExpression) { + traverseArrayExpression(program, map, option, parentNode); + } if (option.kind === SyntaxKind.IntersectionExpression) { traverseIntersectionExpression(program, map, option, parentNode); } @@ -468,6 +479,29 @@ function traverseUnionStatement( } } +function traverseArrayExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: ArrayExpressionNode, + parentNode: Node | null, +): void { + map.set(node, parentNode); + // Array or [] is a reference type, so we need to resolve its original Array model + const type = program.checker.getTypeForNode(node); + + if (type.node) { + const childNode = type.node; + map.set(childNode, node); + const elementType = node.elementType; + if (elementType.kind === SyntaxKind.UnionExpression) { + traverseUnionExpression(program, map, elementType, childNode); + } + if (elementType.kind === SyntaxKind.TypeReference) { + traverseTypeReference(program, map, elementType, childNode); + } + } +} + function traverseIntersectionExpression( program: Program, map: InlineDocNodeTreeMap, @@ -492,7 +526,8 @@ function getLastDocText(node: Node): string | null { const isAllowedNodeKind = node.kind !== SyntaxKind.TypeReference && node.kind !== SyntaxKind.ModelExpression && - node.kind !== SyntaxKind.IntersectionExpression; + node.kind !== SyntaxKind.IntersectionExpression && + node.kind !== SyntaxKind.ArrayExpression; if (isAllowedNodeKind) return null; const docs = node.docs; if (!docs || docs.length === 0) return null; diff --git a/packages/http/test/response-descriptions.test.ts b/packages/http/test/response-descriptions.test.ts index c25385371d8..8b349643308 100644 --- a/packages/http/test/response-descriptions.test.ts +++ b/packages/http/test/response-descriptions.test.ts @@ -111,6 +111,32 @@ describe("http: response descriptions", () => { ); }); + it("inline doc comments for an operation returnType with an array", async () => { + const op = await getHttpOp( + ` + model Widget { + id: string; + + weight: int32; + color: "red" | "blue"; + } + op read(): /** List widgets */ Widget[]; + `, + ); + + strictEqual(op.responses[0].description, "List widgets"); + }); + + it("inline doc comments for an operation returnType with a scalar", async () => { + const op = await getHttpOp( + ` + op read(): /** List widgets */ string; + `, + ); + + strictEqual(op.responses[0].description, "List widgets"); + }); + it("inline doc comments for an operation returnType with union alias", async () => { const op = await getHttpOp( ` @@ -375,6 +401,24 @@ describe("http: response descriptions", () => { ); }); + // it.only("common", async () => { + // const op = await getHttpOp( + // ` + // model ResponseMeta { + // status?: integer; + // } + + // model SuccessResponse { + // meta: ResponseMeta; + // data: T; + // } + // op read(): /** 📦 */ SuccessResponse<{ content: string }>; + // `, + // ); + + // strictEqual(op.responses[0].description, "📦"); + // }); + it("ordering check", async () => { const docs = [ "/** 1 */", From 319ed502392ed9f8246f70dbe7f7ac12d7fa4802 Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:16:44 +0900 Subject: [PATCH 07/12] Array --- packages/http/src/responses.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index e80568a300c..f0ca9e99d87 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -472,6 +472,9 @@ function traverseUnionStatement( if (option.value.kind === SyntaxKind.TypeReference) { traverseTypeReference(program, map, option.value, parentNode); } + if (option.value.kind === SyntaxKind.ArrayExpression) { + traverseArrayExpression(program, map, option.value, parentNode); + } if (option.value.kind === SyntaxKind.ModelExpression) { map.set(option.value, parentNode); } From d6e47c8352c204b437831e83ca4dc255be16d591 Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:56:07 +0900 Subject: [PATCH 08/12] add a limitation test --- .../http/test/response-descriptions.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/http/test/response-descriptions.test.ts b/packages/http/test/response-descriptions.test.ts index 8b349643308..e4867e9ff97 100644 --- a/packages/http/test/response-descriptions.test.ts +++ b/packages/http/test/response-descriptions.test.ts @@ -159,6 +159,28 @@ describe("http: response descriptions", () => { ); }); + it("limitation: inline doc comments for an operation returnType with an alias", async () => { + // NOTE: Inline doc comments are not valid + // when an alias statement points to a single existing node rather than creating a new collection. + // Due to syntax resolution, Response directly points to Widget, so the inline comment is ignored. + // This is a limitation of TypeSpec's syntax resolution system, where aliases to existing nodes + // don't create intermediate nodes that can carry inline doc. + const op = await getHttpOp( + ` + model Widget { + id: string; + + weight: int32; + color: "red" | "blue"; + } + alias Response = /** invalid */ Widget; + op read(): Response; + `, + ); + + strictEqual(op.responses[0].description, "The request has succeeded."); + }); + it("inline doc comments and @doc and @returnsDoc for an operation returnType with union declaration", async () => { const op = await getHttpOp( ` From 461130131121c8fa5f85d9ab91abd271056cc61f Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:39:43 +0900 Subject: [PATCH 09/12] clean up --- packages/http/src/responses.ts | 101 +++++++++++---------------------- 1 file changed, 34 insertions(+), 67 deletions(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index f0ca9e99d87..8b550d8e9d8 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -348,16 +348,35 @@ function generateInlineDocNodeTreeMap( ) { node = operation.node.signature.returnType; } + return traverseChild(program, new WeakMap(), node, null); +} - const map: InlineDocNodeTreeMap = new WeakMap(); - if (node?.kind === SyntaxKind.UnionExpression) { - traverseUnionExpression(program, map, node, null); - } - if (node?.kind === SyntaxKind.ArrayExpression) { - traverseArrayExpression(program, map, node, null); - } - if (node?.kind === SyntaxKind.TypeReference) { - traverseTypeReference(program, map, node, null); +function traverseChild( + program: Program, + map: InlineDocNodeTreeMap, + node: Node | undefined, + parentNode: Node | null, +): InlineDocNodeTreeMap { + if (!node) return map; + switch (node.kind) { + case SyntaxKind.UnionExpression: + traverseUnionExpression(program, map, node, parentNode); + break; + case SyntaxKind.UnionStatement: + traverseUnionStatement(program, map, node, parentNode); + break; + case SyntaxKind.TypeReference: + traverseTypeReference(program, map, node, parentNode); + break; + case SyntaxKind.ArrayExpression: + traverseArrayExpression(program, map, node, parentNode); + break; + case SyntaxKind.IntersectionExpression: + traverseIntersectionExpression(program, map, node, parentNode); + break; + default: + map.set(node, parentNode); + break; } return map; } @@ -413,27 +432,10 @@ function traverseTypeReference( ): void { map.set(node, parentNode); const type = program.checker.getTypeForNode(node); - const ancestorNode = node; if (type.node) { const childNode = type.node; - const kind = childNode.kind; - if (kind === SyntaxKind.UnionExpression) { - traverseUnionExpression(program, map, childNode, ancestorNode); - } - if (kind === SyntaxKind.UnionStatement) { - traverseUnionStatement(program, map, childNode, ancestorNode); - } - if (kind === SyntaxKind.IntersectionExpression) { - traverseIntersectionExpression(program, map, childNode, ancestorNode); - } - if ( - kind === SyntaxKind.ModelStatement || - kind === SyntaxKind.ModelExpression || - kind === SyntaxKind.ScalarStatement - ) { - map.set(childNode, ancestorNode); - } + traverseChild(program, map, childNode, node); } } @@ -444,18 +446,7 @@ function traverseUnionExpression( parentNode: Node | null, ): void { for (const option of node.options) { - if (option.kind === SyntaxKind.TypeReference) { - traverseTypeReference(program, map, option, parentNode); - } - if (option.kind === SyntaxKind.ArrayExpression) { - traverseArrayExpression(program, map, option, parentNode); - } - if (option.kind === SyntaxKind.IntersectionExpression) { - traverseIntersectionExpression(program, map, option, parentNode); - } - if (option.kind === SyntaxKind.ModelExpression) { - map.set(option, parentNode); - } + traverseChild(program, map, option, parentNode); } } @@ -466,19 +457,7 @@ function traverseUnionStatement( parentNode: Node | null, ) { for (const option of node.options) { - map.set(option, parentNode); - - if (option.kind === SyntaxKind.UnionVariant) { - if (option.value.kind === SyntaxKind.TypeReference) { - traverseTypeReference(program, map, option.value, parentNode); - } - if (option.value.kind === SyntaxKind.ArrayExpression) { - traverseArrayExpression(program, map, option.value, parentNode); - } - if (option.value.kind === SyntaxKind.ModelExpression) { - map.set(option.value, parentNode); - } - } + traverseChild(program, map, option.value, parentNode); } } @@ -495,13 +474,8 @@ function traverseArrayExpression( if (type.node) { const childNode = type.node; map.set(childNode, node); - const elementType = node.elementType; - if (elementType.kind === SyntaxKind.UnionExpression) { - traverseUnionExpression(program, map, elementType, childNode); - } - if (elementType.kind === SyntaxKind.TypeReference) { - traverseTypeReference(program, map, elementType, childNode); - } + const grandChildNode = node.elementType; + traverseChild(program, map, grandChildNode, childNode); } } @@ -511,15 +485,8 @@ function traverseIntersectionExpression( node: IntersectionExpressionNode, parentNode: Node | null, ): void { - map.set(node, parentNode); - for (const option of node.options) { - if (option.kind === SyntaxKind.UnionExpression) { - traverseUnionExpression(program, map, option, parentNode); - } - if (option.kind === SyntaxKind.TypeReference) { - traverseTypeReference(program, map, option, parentNode); - } + traverseChild(program, map, option, parentNode); } } From 25b97cb0373d5bb42618818b5715d116e14e597b Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:42:37 +0900 Subject: [PATCH 10/12] changes --- .chronus/changes/feat-response-doc-2025-11-22-13-13-51.md | 3 --- .chronus/changes/feat-response-doc-2025-11-25-23-40-21.md | 7 +++++++ 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 .chronus/changes/feat-response-doc-2025-11-25-23-40-21.md diff --git a/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md index 5a9bd1f2955..d0fd76faec0 100644 --- a/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md +++ b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md @@ -2,10 +2,7 @@ changeKind: feature packages: - "@typespec/compiler" - - "@typespec/http" --- Parse inline doc comments. -Associate parsed inline doc comments with response descriptions. Change formatter behavior to improve the readability of inline doc comments. -Documentation for website about this feature and ordering of what doc will be adopted. diff --git a/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md b/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md new file mode 100644 index 00000000000..669d01ed556 --- /dev/null +++ b/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Associate parsed inline doc comments with response descriptions. From 555cf8133b7126a7e4e4b0f39d55ebb2971b9962 Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:11:20 +0900 Subject: [PATCH 11/12] support intrinsic --- packages/http/src/responses.ts | 52 +++++++++++++++++-- .../http/test/response-descriptions.test.ts | 10 ++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 8b550d8e9d8..7e559c144d9 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -280,6 +280,8 @@ function getResponseDescription( ): string | undefined { // If an inline doc comment provided, use that first const inlineDescription = getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, inlineDocNodeTreeMap, responseType.node, ); @@ -338,6 +340,34 @@ function generateInlineDocNodeTreeMap( program: Program, operation: Operation, ): InlineDocNodeTreeMap { + const node = getOperationReturnTypeNode(operation); + return traverseChild(program, new WeakMap(), node, null); +} + +/** + * If the {@link Operation.returnType} is an intrinsic type, and + * there is no {@link responseTypeNode} to start traversing, + * get the inline doc comment from the intrinsic type's node. + * + * e.g., + * ```typespec + * op read(): /** void type *\/ void; + * ``` + */ +function getInlineDescriptionFromOperationReturnTypeIntrinsic( + program: Program, + operation: Operation, + responseTypeNode?: Node, +): string | null { + const tk = $(program); + const returnTypeNode = getOperationReturnTypeNode(operation); + if (!responseTypeNode && returnTypeNode && tk.intrinsic.is(operation.returnType)) { + return getLastDocText(returnTypeNode); + } + return null; +} + +function getOperationReturnTypeNode(operation: Operation): Node | undefined { let node = operation.returnType.node; // if the return type node of operation is a single type reference, which doesn't appear in AST // about operation.returnType.node @@ -348,7 +378,7 @@ function generateInlineDocNodeTreeMap( ) { node = operation.node.signature.returnType; } - return traverseChild(program, new WeakMap(), node, null); + return node; } function traverseChild( @@ -387,12 +417,15 @@ function traverseChild( * Return the nearest inline description from the {@link Operation.returnType}'s node. */ function getNearestInlineDescriptionFromOperationReturnTypeNode( + program: Program, + operation: Operation, map: InlineDocNodeTreeMap, node?: Node, nearestNodeHasDoc?: Node, ): string | null { - // this branch couldn't happen normally - if (!node) return null; + if (!node) { + return getInlineDescriptionFromOperationReturnTypeIntrinsic(program, operation, node); + } const parentNode = map.get(node); const nodeText = getLastDocText(node); // if no parent, stop traversing and return the description @@ -412,10 +445,18 @@ function getNearestInlineDescriptionFromOperationReturnTypeNode( // if parent has no description and current node has description, // keep current node as nearestNodeHasDoc which could have inline doc comment if (!parentNodeText && nodeText) { - return getNearestInlineDescriptionFromOperationReturnTypeNode(map, parentNode, node); + return getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, + map, + parentNode, + node, + ); } // keep nearestNodeHasDoc as nearest node which could have inline doc comment return getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, map, parentNode, nearestNodeHasDoc, @@ -497,7 +538,8 @@ function getLastDocText(node: Node): string | null { node.kind !== SyntaxKind.TypeReference && node.kind !== SyntaxKind.ModelExpression && node.kind !== SyntaxKind.IntersectionExpression && - node.kind !== SyntaxKind.ArrayExpression; + node.kind !== SyntaxKind.ArrayExpression && + node.kind !== SyntaxKind.VoidKeyword; if (isAllowedNodeKind) return null; const docs = node.docs; if (!docs || docs.length === 0) return null; diff --git a/packages/http/test/response-descriptions.test.ts b/packages/http/test/response-descriptions.test.ts index e4867e9ff97..2b7f8241f8e 100644 --- a/packages/http/test/response-descriptions.test.ts +++ b/packages/http/test/response-descriptions.test.ts @@ -127,6 +127,16 @@ describe("http: response descriptions", () => { strictEqual(op.responses[0].description, "List widgets"); }); + it("inline doc comments for an operation returnType with a void type", async () => { + const op = await getHttpOp( + ` + op read(): /** void type */ void; + `, + ); + + strictEqual(op.responses[0].description, "void type"); + }); + it("inline doc comments for an operation returnType with a scalar", async () => { const op = await getHttpOp( ` From f8e08eb0fb45db12fee126ff0b827f8bad846c9f Mon Sep 17 00:00:00 2001 From: Hajime-san <41257923+Hajime-san@users.noreply.github.com> Date: Fri, 26 Dec 2025 03:39:31 +0900 Subject: [PATCH 12/12] nit --- packages/http/src/responses.ts | 60 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 7e559c144d9..5468c92d2f8 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -1,4 +1,5 @@ import { + ArrayModelType, compilerAssert, createDiagnosticCollector, Diagnostic, @@ -19,7 +20,9 @@ import { import { ArrayExpressionNode, IntersectionExpressionNode, + ModelStatementNode, Node, + OperationSignatureDeclarationNode, SyntaxKind, TypeReferenceNode, UnionExpressionNode, @@ -45,7 +48,6 @@ export function getResponsesForOperation( const responses = new ResponseIndex(); const tk = $(program); const inlineDocNodeTreeMap = generateInlineDocNodeTreeMap(program, operation); - if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) { // Check if the union itself has a @doc to use as the response description const unionDescription = getDoc(program, responseType); @@ -128,7 +130,6 @@ function processResponseType( if (isNullType(option.type)) { continue; } - processResponseType( program, diagnostics, @@ -289,7 +290,7 @@ function getResponseDescription( return inlineDescription; } - // If a parent union provided a description, use that second + // If a parent union provided a description, use that next if (parentDescription) { return parentDescription; } @@ -328,6 +329,8 @@ function getResponseDescription( * * The key is a {@link Node}, and the value is its semantic parent {@link Node} or `null` if none exists. * It means that is a root {@link Node} if the value is `null`. + * + * NOTE: It's useful to change the type to a {@link Map} when you want to debug it. */ interface InlineDocNodeTreeMap extends WeakMap {} @@ -341,6 +344,7 @@ function generateInlineDocNodeTreeMap( operation: Operation, ): InlineDocNodeTreeMap { const node = getOperationReturnTypeNode(operation); + // set null to the parentNode explicitly to mark it as a root node return traverseChild(program, new WeakMap(), node, null); } @@ -349,7 +353,7 @@ function generateInlineDocNodeTreeMap( * there is no {@link responseTypeNode} to start traversing, * get the inline doc comment from the intrinsic type's node. * - * e.g., + * @example * ```typespec * op read(): /** void type *\/ void; * ``` @@ -369,9 +373,11 @@ function getInlineDescriptionFromOperationReturnTypeIntrinsic( function getOperationReturnTypeNode(operation: Operation): Node | undefined { let node = operation.returnType.node; - // if the return type node of operation is a single type reference, which doesn't appear in AST - // about operation.returnType.node - // so we need to get the actual type reference node from operation signature's return type + /** + * if the return type node of {@link operation} is a single type reference, which doesn't appear in AST + * about {@link operation.returnType.node} + * so we need to get the actual type reference node from {@link OperationSignatureDeclarationNode.returnType} + */ if ( operation.node?.kind === SyntaxKind.OperationStatement && operation.node.signature.kind === SyntaxKind.OperationSignatureDeclaration @@ -442,8 +448,8 @@ function getNearestInlineDescriptionFromOperationReturnTypeNode( const parentNodeText = getLastDocText(parentNode); if (map.has(parentNode)) { - // if parent has no description and current node has description, - // keep current node as nearestNodeHasDoc which could have inline doc comment + /** If parent node has no inline doc comment, and current node has inline doc comment, + * keep current node as {@link nearestNodeHasDoc} which could have inline doc comment */ if (!parentNodeText && nodeText) { return getNearestInlineDescriptionFromOperationReturnTypeNode( program, @@ -453,7 +459,7 @@ function getNearestInlineDescriptionFromOperationReturnTypeNode( node, ); } - // keep nearestNodeHasDoc as nearest node which could have inline doc comment + /** keep {@link nearestNodeHasDoc} as nearest node which could have inline doc comment */ return getNearestInlineDescriptionFromOperationReturnTypeNode( program, operation, @@ -475,8 +481,8 @@ function traverseTypeReference( const type = program.checker.getTypeForNode(node); if (type.node) { - const childNode = type.node; - traverseChild(program, map, childNode, node); + const parentNode = node; + traverseChild(program, map, type.node, parentNode); } } @@ -487,7 +493,8 @@ function traverseUnionExpression( parentNode: Node | null, ): void { for (const option of node.options) { - traverseChild(program, map, option, parentNode); + const node = option; + traverseChild(program, map, node, parentNode); } } @@ -496,9 +503,10 @@ function traverseUnionStatement( map: InlineDocNodeTreeMap, node: UnionStatementNode, parentNode: Node | null, -) { +): void { for (const option of node.options) { - traverseChild(program, map, option.value, parentNode); + const node = option.value; + traverseChild(program, map, node, parentNode); } } @@ -509,13 +517,17 @@ function traverseArrayExpression( parentNode: Node | null, ): void { map.set(node, parentNode); - // Array or [] is a reference type, so we need to resolve its original Array model + /** + * {@link ArrayModelType} or {@link SyntaxKind.ArrayLiteral []} is a reference type, + * we need to resolve its original Array model + */ const type = program.checker.getTypeForNode(node); if (type.node) { + const parentNode = node; const childNode = type.node; - map.set(childNode, node); - const grandChildNode = node.elementType; + map.set(childNode, parentNode); + const grandChildNode = parentNode.elementType; traverseChild(program, map, grandChildNode, childNode); } } @@ -527,19 +539,23 @@ function traverseIntersectionExpression( parentNode: Node | null, ): void { for (const option of node.options) { - traverseChild(program, map, option, parentNode); + const node = option; + traverseChild(program, map, node, parentNode); } } function getLastDocText(node: Node): string | null { - // the doc node isn't an inline doc comment when it belongs to a model statement - // this condition should be an allowlist for nodes which can have inline doc comments + /** + * the doc node isn't an inline doc comment when it belongs to a {@link ModelStatementNode} + * this {@link isAllowedNodeKind} condition should be an allowlist for nodes which can have inline doc comments + */ const isAllowedNodeKind = node.kind !== SyntaxKind.TypeReference && node.kind !== SyntaxKind.ModelExpression && node.kind !== SyntaxKind.IntersectionExpression && node.kind !== SyntaxKind.ArrayExpression && - node.kind !== SyntaxKind.VoidKeyword; + node.kind !== SyntaxKind.VoidKeyword && + node.kind !== SyntaxKind.UnknownKeyword; if (isAllowedNodeKind) return null; const docs = node.docs; if (!docs || docs.length === 0) return null;