Skip to content

Commit e1b5d05

Browse files
committed
feat(api-docs): add multiline formatting for long type definitions
- Add conditional type and infer type support to formatType() - Add multiline formatting for typeLiteral when >80 chars or 4+ properties - Add multiline formatting for union/intersection when >80 chars - Add multiline formatting for long function/method signatures - Fix undefined params handling in formatParamsMultiline() - Add extractTypeRefs support for conditional types - Add comprehensive tests for all new formatting features
1 parent 3de9e24 commit e1b5d05

5 files changed

Lines changed: 431 additions & 28 deletions

File tree

lib/api-docs.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,181 @@ Deno.test("formatType - formats type literal with optional property", () => {
233233
assertEquals(formatType(type), "{ name?: string }");
234234
});
235235

236+
Deno.test("formatType - formats long type literal with multiline", () => {
237+
const type: TsTypeDef = {
238+
repr: "",
239+
kind: "typeLiteral",
240+
typeLiteral: {
241+
methods: [],
242+
properties: [
243+
{
244+
name: "OK",
245+
tsType: { repr: "number", kind: "keyword", keyword: "number" },
246+
},
247+
{
248+
name: "CANCELLED",
249+
tsType: { repr: "number", kind: "keyword", keyword: "number" },
250+
},
251+
{
252+
name: "UNKNOWN",
253+
tsType: { repr: "number", kind: "keyword", keyword: "number" },
254+
},
255+
{
256+
name: "INVALID_ARGUMENT",
257+
tsType: { repr: "number", kind: "keyword", keyword: "number" },
258+
},
259+
],
260+
callSignatures: [],
261+
indexSignatures: [],
262+
},
263+
};
264+
// 4+ properties should trigger multiline formatting
265+
const result = formatType(type);
266+
assertEquals(result.includes("\n"), true);
267+
assertEquals(result.includes("OK: number;"), true);
268+
assertEquals(result.includes("INVALID_ARGUMENT: number;"), true);
269+
});
270+
271+
Deno.test("formatType - formats empty type literal", () => {
272+
const type: TsTypeDef = {
273+
repr: "{}",
274+
kind: "typeLiteral",
275+
typeLiteral: {
276+
methods: [],
277+
properties: [],
278+
callSignatures: [],
279+
indexSignatures: [],
280+
},
281+
};
282+
assertEquals(formatType(type), "{}");
283+
});
284+
285+
Deno.test("formatType - formats conditional type", () => {
286+
const type: TsTypeDef = {
287+
repr: "",
288+
kind: "conditional",
289+
conditionalType: {
290+
checkType: {
291+
repr: "T",
292+
kind: "typeRef",
293+
typeRef: { typeName: "T", typeParams: null },
294+
},
295+
extendsType: { repr: "string", kind: "keyword", keyword: "string" },
296+
trueType: {
297+
repr: "A",
298+
kind: "typeRef",
299+
typeRef: { typeName: "A", typeParams: null },
300+
},
301+
falseType: {
302+
repr: "B",
303+
kind: "typeRef",
304+
typeRef: { typeName: "B", typeParams: null },
305+
},
306+
},
307+
};
308+
assertEquals(formatType(type), "T extends string ? A : B");
309+
});
310+
311+
Deno.test("formatType - formats nested conditional type with multiline", () => {
312+
const type: TsTypeDef = {
313+
repr: "",
314+
kind: "conditional",
315+
conditionalType: {
316+
checkType: {
317+
repr: "R",
318+
kind: "typeRef",
319+
typeRef: { typeName: "R", typeParams: null },
320+
},
321+
extendsType: {
322+
repr: "TypeA",
323+
kind: "typeRef",
324+
typeRef: { typeName: "TypeA", typeParams: null },
325+
},
326+
trueType: {
327+
repr: "ResultA",
328+
kind: "typeRef",
329+
typeRef: { typeName: "ResultA", typeParams: null },
330+
},
331+
falseType: {
332+
repr: "",
333+
kind: "conditional",
334+
conditionalType: {
335+
checkType: {
336+
repr: "R",
337+
kind: "typeRef",
338+
typeRef: { typeName: "R", typeParams: null },
339+
},
340+
extendsType: {
341+
repr: "TypeB",
342+
kind: "typeRef",
343+
typeRef: { typeName: "TypeB", typeParams: null },
344+
},
345+
trueType: {
346+
repr: "ResultB",
347+
kind: "typeRef",
348+
typeRef: { typeName: "ResultB", typeParams: null },
349+
},
350+
falseType: { repr: "never", kind: "keyword", keyword: "never" },
351+
},
352+
},
353+
},
354+
};
355+
const result = formatType(type);
356+
// Nested conditional should have line breaks
357+
assertEquals(result.includes("\n"), true);
358+
assertEquals(result.includes("R extends TypeA"), true);
359+
assertEquals(result.includes("? ResultA"), true);
360+
assertEquals(result.includes(": R extends TypeB"), true);
361+
});
362+
363+
Deno.test("formatType - formats infer type", () => {
364+
const type: TsTypeDef = {
365+
repr: "",
366+
kind: "infer",
367+
infer: {
368+
typeParam: { name: "T" },
369+
},
370+
};
371+
assertEquals(formatType(type), "infer T");
372+
});
373+
374+
Deno.test("formatType - formats long union type with multiline", () => {
375+
const type: TsTypeDef = {
376+
repr: "",
377+
kind: "union",
378+
union: [
379+
{
380+
repr: "VeryLongTypeNameForFirstMember",
381+
kind: "typeRef",
382+
typeRef: {
383+
typeName: "VeryLongTypeNameForFirstMember",
384+
typeParams: null,
385+
},
386+
},
387+
{
388+
repr: "VeryLongTypeNameForSecondMember",
389+
kind: "typeRef",
390+
typeRef: {
391+
typeName: "VeryLongTypeNameForSecondMember",
392+
typeParams: null,
393+
},
394+
},
395+
{
396+
repr: "VeryLongTypeNameForThirdMember",
397+
kind: "typeRef",
398+
typeRef: {
399+
typeName: "VeryLongTypeNameForThirdMember",
400+
typeParams: null,
401+
},
402+
},
403+
],
404+
};
405+
const result = formatType(type);
406+
// Long union (>80 chars) should have line breaks
407+
assertEquals(result.includes("\n"), true);
408+
assertEquals(result.includes("| VeryLongTypeNameForSecondMember"), true);
409+
});
410+
236411
// ============================================================================
237412
// formatParams Tests
238413
// ============================================================================

lib/api-docs.ts

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ export interface TsTypeDef {
145145
callSignatures: CallSignatureDef[];
146146
indexSignatures: IndexSignatureDef[];
147147
};
148+
conditionalType?: {
149+
checkType: TsTypeDef;
150+
extendsType: TsTypeDef;
151+
trueType: TsTypeDef;
152+
falseType: TsTypeDef;
153+
};
154+
infer?: {
155+
typeParam: TsTypeParamDef;
156+
};
148157
}
149158

150159
export interface TsTypeParamDef {
@@ -360,8 +369,11 @@ export interface PackageInfo {
360369

361370
/**
362371
* Format a type definition as a readable string
372+
*
373+
* @param type - The type definition to format
374+
* @param indent - Current indentation level (used for nested conditionals)
363375
*/
364-
export function formatType(type?: TsTypeDef): string {
376+
export function formatType(type?: TsTypeDef, indent = 0): string {
365377
if (!type) return "unknown";
366378

367379
switch (type.kind) {
@@ -373,21 +385,43 @@ export function formatType(type?: TsTypeDef): string {
373385
const params = type.typeRef.typeParams;
374386
if (params && params.length > 0) {
375387
return `${type.typeRef.typeName}<${
376-
params.map(formatType).join(", ")
388+
params.map((p) => formatType(p, indent)).join(", ")
377389
}>`;
378390
}
379391
return type.typeRef.typeName;
380392
}
381393
return type.repr;
382394

383395
case "array":
384-
return `${formatType(type.array)}[]`;
385-
386-
case "union":
387-
return type.union?.map(formatType).join(" | ") ?? type.repr;
396+
return `${formatType(type.array, indent)}[]`;
397+
398+
case "union": {
399+
if (!type.union) return type.repr;
400+
const members = type.union.map((t) => formatType(t, indent));
401+
const inline = members.join(" | ");
402+
// Break into multiple lines if too long (>80 chars) and has multiple members
403+
if (inline.length > 80 && members.length > 1) {
404+
const spaces = " ".repeat(indent);
405+
return members.map((m, i) => i === 0 ? m : `${spaces}| ${m}`).join(
406+
"\n",
407+
);
408+
}
409+
return inline;
410+
}
388411

389-
case "intersection":
390-
return type.intersection?.map(formatType).join(" & ") ?? type.repr;
412+
case "intersection": {
413+
if (!type.intersection) return type.repr;
414+
const members = type.intersection.map((t) => formatType(t, indent));
415+
const inline = members.join(" & ");
416+
// Break into multiple lines if too long (>80 chars) and has multiple members
417+
if (inline.length > 80 && members.length > 1) {
418+
const spaces = " ".repeat(indent);
419+
return members.map((m, i) => i === 0 ? m : `${spaces}& ${m}`).join(
420+
"\n",
421+
);
422+
}
423+
return inline;
424+
}
391425

392426
case "literal":
393427
if (type.literal) {
@@ -400,30 +434,67 @@ export function formatType(type?: TsTypeDef): string {
400434
return type.repr;
401435

402436
case "tuple":
403-
return `[${type.tuple?.map(formatType).join(", ") ?? ""}]`;
437+
return `[${
438+
type.tuple?.map((t) => formatType(t, indent)).join(", ") ?? ""
439+
}]`;
404440

405441
case "fnOrConstructor": {
406442
const fn = type.fnOrConstructor;
407443
if (!fn) return type.repr;
408444
const params = fn.params.map((p) =>
409-
`${p.name ?? "_"}: ${formatType(p.tsType)}`
445+
`${p.name ?? "_"}: ${formatType(p.tsType, indent)}`
410446
).join(", ");
411-
return `(${params}) => ${formatType(fn.returnType)}`;
447+
return `(${params}) => ${formatType(fn.returnType, indent)}`;
412448
}
413449

414450
case "typeOperator": {
415451
const op = type.typeOperator;
416452
if (!op) return type.repr;
417-
return `${op.operator} ${formatType(op.tsType)}`;
453+
return `${op.operator} ${formatType(op.tsType, indent)}`;
418454
}
419455

420456
case "typeLiteral": {
421457
const lit = type.typeLiteral;
422458
if (!lit) return type.repr;
423-
const props = lit.properties.map((p) =>
424-
`${p.name}${p.optional ? "?" : ""}: ${formatType(p.tsType)}`
425-
).join("; ");
426-
return `{ ${props} }`;
459+
const propStrings = lit.properties.map((p) =>
460+
`${p.name}${p.optional ? "?" : ""}: ${formatType(p.tsType, indent + 1)}`
461+
);
462+
if (propStrings.length === 0) return "{}";
463+
const inline = `{ ${propStrings.join("; ")} }`;
464+
// Break into multiple lines if too long (>80 chars) or has many properties
465+
if (inline.length > 80 || propStrings.length > 3) {
466+
const spaces = " ".repeat(indent);
467+
const multiline = propStrings
468+
.map((s) => `${spaces} ${s};`)
469+
.join("\n");
470+
return `{\n${multiline}\n${spaces}}`;
471+
}
472+
return inline;
473+
}
474+
475+
case "conditional": {
476+
const cond = type.conditionalType;
477+
if (!cond) return type.repr;
478+
479+
const check = formatType(cond.checkType, indent);
480+
const ext = formatType(cond.extendsType, indent);
481+
const trueT = formatType(cond.trueType, indent);
482+
483+
// If falseType is also conditional, format with line breaks for readability
484+
if (cond.falseType.kind === "conditional") {
485+
const spaces = " ".repeat(indent);
486+
const falseT = formatType(cond.falseType, indent + 1);
487+
return `${check} extends ${ext}\n${spaces} ? ${trueT}\n${spaces} : ${falseT}`;
488+
}
489+
490+
const falseT = formatType(cond.falseType, indent);
491+
return `${check} extends ${ext} ? ${trueT} : ${falseT}`;
492+
}
493+
494+
case "infer": {
495+
const inf = type.infer;
496+
if (!inf) return type.repr;
497+
return `infer ${inf.typeParam.name}`;
427498
}
428499

429500
default:

lib/signature-formatters.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,3 +637,61 @@ Deno.test("formatConstructorSignature - formats constructor with optional params
637637
"new Service(config?: Config)",
638638
);
639639
});
640+
641+
// ============================================================================
642+
// Edge Cases: undefined params handling
643+
// ============================================================================
644+
645+
Deno.test("formatMethodSignature - handles undefined params", () => {
646+
// MethodDef.params can be undefined in some deno doc JSON output
647+
const method = {
648+
name: "doSomething",
649+
typeParams: [],
650+
params: undefined,
651+
returnType: { repr: "void", kind: "keyword", keyword: "void" },
652+
} as unknown as MethodDef;
653+
// Should not throw, should handle gracefully
654+
const result = formatMethodSignature(method);
655+
assertEquals(result, "doSomething(): void");
656+
});
657+
658+
Deno.test("formatFunctionSignature - formats long signature with multiline", () => {
659+
const params: ParamDef[] = [
660+
{
661+
kind: "identifier",
662+
name: "veryLongParameterName1",
663+
tsType: { repr: "string", kind: "keyword", keyword: "string" },
664+
},
665+
{
666+
kind: "identifier",
667+
name: "veryLongParameterName2",
668+
tsType: { repr: "number", kind: "keyword", keyword: "number" },
669+
},
670+
{
671+
kind: "identifier",
672+
name: "veryLongParameterName3",
673+
tsType: { repr: "boolean", kind: "keyword", keyword: "boolean" },
674+
},
675+
];
676+
const def: FunctionDef = {
677+
params,
678+
hasBody: true,
679+
isAsync: false,
680+
isGenerator: false,
681+
typeParams: [],
682+
returnType: { repr: "void", kind: "keyword", keyword: "void" },
683+
};
684+
const result = formatFunctionSignature("myFunctionWithALongName", def);
685+
// Long signature should have line breaks
686+
assertEquals(result.includes("\n"), true);
687+
assertEquals(result.includes("veryLongParameterName1: string"), true);
688+
});
689+
690+
Deno.test("formatConstructorSignature - handles undefined params", () => {
691+
// Should not throw when params is undefined
692+
const result = formatConstructorSignature(
693+
"MyClass",
694+
undefined as unknown as ParamDef[],
695+
);
696+
assertEquals(result, "new MyClass()");
697+
});

0 commit comments

Comments
 (0)