tdjson
Version:
TypeScript types for libtdjson.so
463 lines (385 loc) • 11.7 kB
text/typescript
import fs from 'node:fs/promises';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { inspect } from 'node:util';
import fetch from 'node-fetch';
import { outdent } from 'outdent';
import invariant from 'invariant';
import * as A from 'arcsecond';
import { pascalCase } from 'pascal-case';
import * as ttyStrings from 'tty-strings';
function wordWrap(text: string): string {
return ttyStrings.wordWrap(text, 120);
}
function trimFilterJoin(strings: string[]): string {
return strings.map(s => s.trim()).filter(s => s.length > 0).join(' ');
}
interface Identifier {
readonly type: 'Identifier';
readonly name: string;
}
const identifierParser = A.regex(/^\w+/).map(name => ({
type: 'Identifier',
name,
} as const));
interface Comment {
readonly type: 'Comment';
readonly tag: string;
readonly text: string;
}
const commentParser = A.sequenceOf([
A.str('//@'),
identifierParser,
A.everyCharUntil(A.char('\n')),
A.many(A.sequenceOf([
A.char('\n'),
A.str('//-'),
A.everyCharUntil(A.char('\n')),
]).map(([ _0, _1, comment ]) => comment)).map(s => trimFilterJoin(s)),
]).map(([ _0, tag, comment, comment_ ]) => ({
type: 'Comment',
tag: tag.name,
text: trimFilterJoin([ comment, comment_ ]),
} as const));
const commentsParser: A.Parser<Comment[]> = A.many(A.sequenceOf([
A.optionalWhitespace,
commentParser,
]).map(([ _, comment ]) => comment)).map(comments => comments.flatMap(comment => {
const [ head, ...tail ] = comment.text.split('@');
return [
{
...comment,
text: head,
} as const,
...tail.map(commentText => {
const [ tag, ...textWords ] = commentText.split(' ');
return {
type: 'Comment',
tag,
text: textWords.join(' '),
} as const;
}),
];
}));
interface TypeApp {
readonly type: 'TypeApplication';
readonly callee: Identifier;
readonly argument: Identifier | TypeApp;
}
const typeAppParser = A.sequenceOf([
identifierParser,
A.char('<'),
A.recursiveParser(() => typeParser),
A.char('>'),
]).map(([ callee, _1, argument ]) => ({
type: 'TypeApplication',
callee,
argument,
} as const));
const typeParser: A.Parser<Identifier | TypeApp> = A.choice([
typeAppParser,
identifierParser,
]);
interface AnnotatedIdentifier {
readonly type: 'AnnotatedIdentifier';
readonly id: Identifier;
readonly typeAnnotation: Identifier | TypeApp;
}
const annotatedIdentifierParser = A.sequenceOf([
identifierParser,
A.char(':'),
typeParser,
]).map(([ id, _1, typeAnnotation ]) => ({
type: 'AnnotatedIdentifier',
id,
typeAnnotation,
} as const));
const typeVariableParser = A.sequenceOf([
A.char('{'),
annotatedIdentifierParser,
A.char('}'),
]).map(([ _0, annotatedIdentifier ]) => annotatedIdentifier);
const argumentParser = A.choice([
A.char('?'),
A.char('#'),
A.str('[ t ]'),
annotatedIdentifierParser,
identifierParser,
typeVariableParser,
]);
const argumentsParser = A.many(A.sequenceOf([
A.whitespace,
argumentParser,
]).map(([ _0, argument ]) => argument));
interface Combinator {
type: 'Combinator';
comments: Comment[];
left: {
id: Identifier;
arguments: Array<string | Identifier | AnnotatedIdentifier>;
};
right: {
id: Identifier;
arguments: Array<string | Identifier | AnnotatedIdentifier>;
};
}
const combinatorParser: A.Parser<Combinator> = A.sequenceOf([
identifierParser,
argumentsParser,
A.whitespace,
A.char('='),
A.whitespace,
identifierParser,
A.possibly(argumentsParser),
]).map(([ name, arguments_, _2, _3, _4, resultName, resultArguments ]) => ({
type: 'Combinator',
comments: [],
left: {
id: name,
arguments: arguments_ ?? [],
} as const,
right: {
id: resultName,
arguments: resultArguments ?? [],
} as const,
}));
const tdApiParser: A.Parser<Combinator> = A.sequenceOf([
A.possibly(A.sequenceOf([
commentsParser,
A.whitespace,
]).map(([ comments ]) => comments)),
combinatorParser,
]).map(([ comments, combinator ]) => ({
...combinator,
comments: [ ...combinator.comments, ...(comments ?? []) ],
}));
function stringifyTypeAnnotation(typeAnnotation: Identifier | TypeApp): string {
if (typeAnnotation.type === 'Identifier') {
if (
typeAnnotation.name === 'String'
|| typeAnnotation.name === 'Int64'
|| typeAnnotation.name === 'string'
|| typeAnnotation.name === 'int64'
) {
return 'string';
}
if (
typeAnnotation.name === 'Double'
|| typeAnnotation.name === 'Int32'
|| typeAnnotation.name === 'Int53'
|| typeAnnotation.name === 'double'
|| typeAnnotation.name === 'int32'
|| typeAnnotation.name === 'int53'
) {
return 'number';
}
if (
typeAnnotation.name === 'Bool'
|| typeAnnotation.name === 'bool'
) {
return 'boolean';
}
if (
typeAnnotation.name === 'Bytes'
|| typeAnnotation.name === 'bytes'
) {
return 'string';
}
if (
typeAnnotation.name === 'Vector'
|| typeAnnotation.name === 'vector'
) {
return 'Array';
}
return pascalCase(typeAnnotation.name);
}
if (typeAnnotation.type === 'TypeApplication') {
return `${stringifyTypeAnnotation(typeAnnotation.callee)}<${stringifyTypeAnnotation(typeAnnotation.argument)}>`;
}
invariant(false, 'Cannot stringify type annotation: %s', inspect(typeAnnotation));
}
function stringifyCommentText(lines: Array<undefined | string>, thisInterfaceName?: string): string {
let text = lines.map(line => line?.trim()).filter(Boolean).map(line => {
if (line && !line.endsWith('.')) {
line += '.';
}
return line;
}).join('\n');
if (thisInterfaceName) {
text = text.replaceAll(/(?<!{)@(\w+)/g, (_, referencedIdentifier: string) => `{@link ${thisInterfaceName}#${referencedIdentifier}}`);
}
return wordWrap(text);
}
function stringifyConstructorName(constructor_: Combinator) {
return pascalCase(constructor_.left.id.name);
}
function stringifyConstructorArgument(constructor_: Combinator, argument: string | Identifier | AnnotatedIdentifier) {
invariant(
typeof argument === 'object'
&& argument.type === 'AnnotatedIdentifier',
'Can not stringify constructor argument: %s',
inspect(argument),
);
const interfaceName = stringifyConstructorName(constructor_);
const comment = constructor_.comments.find(comment => comment.tag === argument.id.name);
const commentText = stringifyCommentText([ comment?.text ], interfaceName);
const typeAnnotation = stringifyTypeAnnotation(argument.typeAnnotation);
const isOptional = typeAnnotation === 'boolean';
const optionalPropertySign = isOptional ? '?' : '';
return outdent`
/**
${commentText}
*/
${argument.id.name}${optionalPropertySign}: ${typeAnnotation};
`;
}
function stringifyConstructor(constructor_: Combinator, isFunction = false) {
if (
constructor_.right.id.name === 'Double'
|| constructor_.right.id.name === 'String'
|| constructor_.right.id.name === 'Int32'
|| constructor_.right.id.name === 'Int53'
|| constructor_.right.id.name === 'Int64'
|| constructor_.right.id.name === 'Bytes'
|| constructor_.right.id.name === 'Bool'
|| constructor_.right.id.name === 'Vector'
) {
return '';
}
const interfaceName = stringifyConstructorName(constructor_);
const constructorCommentText = (
isFunction
? outdent`
Request type for {@link Tdjson#${constructor_.left.id.name}}.
`
: (
constructor_.right.id.name === interfaceName
? ''
: outdent`
Subtype of {@link ${constructor_.right.id.name}}.
`
)
);
const descriptionComment = constructor_.comments.find(comment => comment.tag === 'description');
const descriptionCommentText = stringifyCommentText([
descriptionComment?.text,
constructorCommentText,
], interfaceName);
return outdent`
/**
${descriptionCommentText}
*/
export interface ${interfaceName} {
'@type': ${JSON.stringify(constructor_.left.id.name)};
${constructor_.left.arguments.map(argument => stringifyConstructorArgument(constructor_, argument)).join('\n')}
}
`;
}
function stringifyFunctionOptionsType(function_: Combinator) {
const interfaceName = stringifyConstructorName(function_);
return `Omit<${interfaceName}, '@type'>`;
}
function stringifyFunction(function_: Combinator) {
const descriptionComment = function_.comments.find(comment => comment.tag === 'description');
const descriptionCommentText = stringifyCommentText([
descriptionComment?.text,
]);
const hasArguments = function_.left.arguments.length > 0;
if (!hasArguments) {
return outdent`
/**
${descriptionCommentText}
*/
${function_.left.id.name}(): Promise<${function_.right.id.name}> {
return this._request({
'@type': ${JSON.stringify(function_.left.id.name)},
});
}
`;
}
return outdent`
/**
${descriptionCommentText}
*/
${function_.left.id.name}(options: ${stringifyFunctionOptionsType(function_)}): Promise<${function_.right.id.name}> {
return this._request({
...options,
'@type': ${JSON.stringify(function_.left.id.name)},
});
}
`;
}
function stringifyFunctions(functions: Combinator[]) {
return outdent`
export abstract class Tdjson {
${functions.map(f => stringifyFunction(f)).join('\n')}
/**
Send a request to the actual libtdjson.so here. Do not forget to handle {@link Error} responses and timeouts.
*/
protected abstract _request<R extends Request>(message: R): Promise<any>;
}
`;
}
function stringifyConstructorUnion(unionName: string, constructors: Iterable<Combinator>) {
if (unionName === 'Bool') {
return '';
}
const constructorsUnion = (
[ ...constructors ]
.map(constructor_ => '| ' + stringifyConstructorName(constructor_))
.join('\n')
);
return outdent`
export type ${unionName} =
${constructorsUnion};
`;
}
async function main() {
const response = await fetch('https://raw.githubusercontent.com/tdlib/td/master/td/generate/scheme/td_api.tl');
let text = await response.text();
text = text.replaceAll(/\n+/g, '\n').replaceAll(/[ \t]+/g, ' ');
const [ constructorsText, functionsText ] = text.split('---functions---');
const [ constructors, functions ] = [ constructorsText, functionsText ].map(text => {
const lines = text.split(/;\n(?!\/\/-)/).flatMap(line => {
line = line.trim();
if (!line) {
return [];
}
return [ line ];
});
const parseResult = lines.flatMap(line => {
const lineParseResult = tdApiParser.run(line);
const { isError, index } = lineParseResult;
if (isError || index !== Buffer.byteLength(line, 'utf8')) {
const before = line.slice(0, index);
const after = line.slice(index, -1);
console.error(lineParseResult, line.length);
console.error([
before,
after,
].join('<<HERE>>').trim());
invariant(false, 'Parsing failed or did not consume everything');
}
return [ lineParseResult.result ];
});
return parseResult;
});
const constructorUnions = constructors.reduce((groups, constructor_) => {
if (!groups.has(constructor_.right.id.name)) {
groups.set(constructor_.right.id.name, new Set());
}
const group = groups.get(constructor_.right.id.name)!;
group.add(constructor_);
return groups;
}, new Map<string, Set<Combinator>>());
const typescript = [
'/* eslint-disable @typescript-eslint/no-unsafe-return */',
...constructors.map(c => stringifyConstructor(c)),
...[ ...constructorUnions.entries() ].filter(([ _, group ]) => group.size > 1).map(([ unionName, constructors ]) => stringifyConstructorUnion(unionName, constructors)),
...functions.map(c => stringifyConstructor(c, true)),
stringifyConstructorUnion('Request', functions),
stringifyFunctions(functions),
].filter(Boolean).join('\n\n');
await fs.writeFile(path.join(__dirname, 'tdjson.ts'), typescript);
}
void main();