Skip to content

Q: How to document CallExpression/CallSignature with TypeDoc? #2498

Closed
@tristanzander

Description

@tristanzander

Search terms

CallSignature, CallExpression, CucumberJS, Function Calls, Custom Reflections

Question

TL;DR

  1. Is there a generic way to convert a ts.JSDoc into a list of TypeDoc tokens that I can put into a reflection?
  2. Is there a way to register a custom reflection explicitly dedicated to function CallExpressions that is placed in it's own folder?

Related to a thread on Discord.

Details

I'm using the CucumberJS framework for some integration tests, and we distribute some framework-specific code to other people in the form of step definitions. These step definitions are function calls that are registered at runtime to provide BDD assertions in the form of Gherkins. The following is an example of a step definition (taken straight from the docs with an added TSDoc):

import { Given } from '@cucumber/cucumber';

/**
 * Prepare the actor with a specific number of cucumbers having already being eaten.
 *
 * @param cucumberCount The number of cucumbers to eat
 * 
 * @example
 * ```
 * ...
 * ```
 */
Given('I have {int} cucumbers in my belly', function (cucumberCount: number) {
  assert.equal(this.responseStatus, cucumberCount)
});

I want to write a plugin that is able to convert those function calls into readable documentation. So far, it's been going quite nicely in the conversion stage of things, but I'm getting stuck after having extracted the function details and JSDoc from the function. Is there a generic method that can convert a ts.JSDoc to a typedoc.Comment or some form of tokens that I can place directly into a reflection? I was looking at lexBlockComment as that solution, but it's not publicly available through the exports. It might be a little bit too low-level for my needs anyway, but I'm not seeing much else available for my inputs.

Additionally, I haven't quite figured out how to register a reflection that goes into it's own folder. I notice that each part of a reflection can get its own folder (ex. "classes", "enums", "functions", "types", "modules") based on the ReflectionKind that's used in the DeclarationReflection. There doesn't seem to be a way to create a custom ReflectionKind and have it create a unique folder. This is a very niche use-case, so I might be the first person to try something like this.

Any help I can get with these questions would be greatly appreciated! 🙏

Current Demo Plugin

Here's what I have implemented so far. It's fairly basic, but you can see the part in readSourceFile() where it extracts the functions that I'm attempting to convert to docs.

Demo Plugin Code
import { glob } from 'glob';
import {
	Application,
	Context,
	Converter,
	DeclarationReflection,
	ParameterType,
	ReflectionKind,
	Comment,
	TypeScript as ts,
} from 'typedoc';

const CUCUMBER_FUNCTION_NAMES: CucumberFunctionNames[] = [
	'Given',
	'When',
	'Then',
	'Before',
	'After',
	'AfterAll',
	'BeforeAll',
	'BeforeEach',
	'AfterEach',
];

type CucumberFunctionNames =
	| 'Given'
	| 'When'
	| 'Then'
	| 'Before'
	| 'After'
	| 'BeforeAll'
	| 'AfterAll'
	| 'BeforeEach'
	| 'AfterEach';

function getUniquePrograms(programs: readonly ts.Program[]): readonly ts.Program[] {
	const uniqueMap: Record<string, ts.Program[]> = programs.reduce((acc, p) => {
		const root = p.getCurrentDirectory();
		acc[root] = acc[root] ?? [];
		acc[root].push(p);
		return acc;
	}, {});
	return Object.values(uniqueMap).map((pArray) => pArray[0]);
}

/**
 * Reads a TypeScript file and extracts any documentation on Step Definitions.
 * @param sourceFile The TypeScript source file
 */
function readSourceFile(sourceFile: ts.SourceFile) {
	// These are what store the JSDocs
	const toplevelExpressions = sourceFile.statements.filter(
		(f) => f.kind == ts.SyntaxKind.ExpressionStatement
	) as ts.ExpressionStatement[];

	const sources: {
		sourceFile: ts.SourceFile;
		jsDoc: ts.JSDoc;
		functionName: CucumberFunctionNames;
	}[] = [];

	for (const expression of toplevelExpressions) {
		const cucumberFunctionName = isCucumberFunctionCall(expression);
		if (cucumberFunctionName === false) {
			// Not a cucumber function
			continue;
		}

		if (expression['jsDoc'] === undefined) {
			continue;
		}

		const jsDoc = expression['jsDoc'] as [ts.JSDoc];
		// const docComment = sourceFile.getFullText().slice(jsDoc[0].pos, jsDoc[0].end);

		sources.push({
			functionName: cucumberFunctionName,
			sourceFile,
			jsDoc: jsDoc[0],
		});
	}
}

/**
 * Determines if the expression is a cucumber support code function call.
 * @param expression
 * @returns the name of the cucumber function that was called, or false in a situation where the expression is not a function call or a cucumber function.
 */
function isCucumberFunctionCall(expression: ts.ExpressionStatement): CucumberFunctionNames | false {
	const innerExpression = expression.expression as ts.CallExpression;
	if (innerExpression.kind !== ts.SyntaxKind.CallExpression) {
		return false;
	}

	if (innerExpression.expression.kind !== ts.SyntaxKind.Identifier) {
		return false;
	}

	if (
		!CUCUMBER_FUNCTION_NAMES.includes(
			(innerExpression.expression as ts.Identifier).escapedText.toString() as any
		)
	) {
		return false;
	}

	return (
		innerExpression.expression as ts.Identifier
	).escapedText.toString() as CucumberFunctionNames;
}

export function load(app: Application) {
	app.options.addDeclaration({
		name: 'stepDefinitions',
		help:
			'Extract TSDocs from cucumber step definitions listed at the specified paths. ' +
			'Should be a list of files relative to the project root, represented as a relative path or glob.',
		type: ParameterType.GlobArray,
		defaultValue: [],
	});

	app.converter.on(Converter.EVENT_RESOLVE_BEGIN, async (ctx: Context) => {
		const paths = ctx.converter.application.options.getValue('stepDefinitions') as string[];

		if (paths.length == 0) {
			return;
		}

		const resolvedPaths = (await Promise.all(paths.map((p) => glob(p)))).flat();

		for (const program of getUniquePrograms(ctx.programs)) {
			for (const path of resolvedPaths) {
				const sourceFile = program.getSourceFile(path);

				if (!sourceFile) {
					continue;
				}

				readSourceFile(sourceFile);
			}
		}

		const reflection = new DeclarationReflection('customThingy', ReflectionKind.Module);
		reflection.comment = new Comment(
			[
				{
					kind: 'text',
					text: 'this is a custom comment',
				},
			],
			[]
		);

		reflection.children = [
                         // attempting to see if I can register a custom module with a "function" declaration, where the function
                         // is just the information I extract from the step definitions
			new DeclarationReflection('fakeCallSignature', ReflectionKind.Function, reflection),
		];

		ctx.project.children?.push(reflection);
	});
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionQuestion about functionality

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions