Files
libsignal/rust/bridge/node/bin/gen_ts_decl.py
Alex Bakon 1b2304022a Expose net remote config keys in TypeScript
Co-authored-by: Max Moiseev <moiseev@signal.org>
2025-11-06 15:17:32 -08:00

333 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Copyright (C) 2020-2021 Signal Messenger, LLC.
# SPDX-License-Identifier: AGPL-3.0-only
#
import collections
import difflib
import itertools
import os
import re
import subprocess
import sys
from typing import Iterable, Iterator, Tuple
Args = collections.namedtuple('Args', ['verify'])
def parse_args() -> Args:
def print_usage_and_exit() -> None:
print('usage: %s [--verify]' % sys.argv[0], file=sys.stderr)
sys.exit(2)
# If the command-line handling below gets any more complicated, this should be switched to argparse.
mode = None
if len(sys.argv) > 2:
print_usage_and_exit()
elif len(sys.argv) == 2:
mode = sys.argv[1]
if mode != '--verify':
print_usage_and_exit()
return Args(verify=mode is not None)
def split_rust_args(args: str) -> Iterator[Tuple[str, str]]:
"""
Split Rust `arg: Type` pairs separated by commas.
Account for templates, tuples, and slices.
"""
while ':' in args:
(name, args) = args.split(':', maxsplit=1)
if name.startswith('mut '):
name = name[4:]
open_pairs = 0
for (i, c) in enumerate(args):
if c == ',' and open_pairs == 0:
ty = args[:i]
args = args[i + 1:]
yield (name.strip(), ty.strip())
break
elif c in ['<', '(', '[']:
open_pairs += 1
elif c in ['>', ')', ']']:
open_pairs -= 1
else:
yield (name.strip(), args.strip())
def translate_to_ts(typ: str) -> str:
typ = typ.replace(' ', '')
type_map = {
'()': 'void',
'&[u8]': 'Uint8Array',
'i32': 'number',
'u8': 'number',
'u16': 'number',
'u32': 'number',
'u64': 'bigint',
'bool': 'boolean',
'String': 'string',
'&str': 'string',
'Vec<u8>': 'Uint8Array',
'Box<[u8]>': 'Uint8Array',
'ServiceId': 'Uint8Array',
'Aci': 'Uint8Array',
'Pni': 'Uint8Array',
'E164': 'string',
"ServiceIdSequence<'_>": 'Uint8Array',
'PathAndQuery': 'string',
'LanguageList': 'string[]',
'&BackupKey': 'Uint8Array',
'MultiRecipientSendAuthorization': 'Uint8Array|null',
}
if typ in type_map:
return type_map[typ]
if typ.startswith('[u8;') or typ.startswith('&[u8;'):
return 'Uint8Array'
if typ.startswith('&mutdyn'):
return typ[7:]
if typ.startswith('&dyn'):
return typ[4:]
if typ.startswith('&mut'):
return 'Wrapper<' + typ[4:] + '>'
if typ.startswith('&[&'):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[3:-1]) + '>[]'
if typ.startswith('Box<['):
assert typ.endswith(']>')
return translate_to_ts(typ[5:-2]) + '[]'
if typ.startswith('Box<dyn'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1])
if typ.startswith('Vec<'):
assert typ.endswith('>')
return translate_to_ts(typ[4:-1]) + '[]'
if typ.startswith('&['):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[2:-1]) + '>[]'
if typ.startswith('&'):
return 'Wrapper<' + typ[1:] + '>'
if typ.startswith('('):
assert typ.endswith(')'), typ
inner = typ[1:-1].split(',')
if len(inner) == 1:
return translate_to_ts(inner[0])
return '[' + ', '.join(translate_to_ts(x) for x in inner) + ']'
if typ.startswith('Option<'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1]) + ' | null'
if typ.startswith('Result<'):
assert typ.endswith('>')
type_args = typ[7:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('std::result::Result<'):
assert typ.endswith('>')
type_args = typ[20:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('Promise<'):
assert typ.endswith('>')
return 'Promise<' + translate_to_ts(typ[8:-1]) + '>'
if typ.startswith('CancellablePromise<'):
assert typ.endswith('>')
return 'CancellablePromise<' + translate_to_ts(typ[19:-1]) + '>'
if typ.startswith('AsType<'):
assert typ.endswith('>')
assert ',' in typ
return translate_to_ts(typ.split(',')[1][:-1])
if typ.startswith('Ignored<'):
assert typ.endswith('>')
return 'null'
return typ
DIAGNOSTICS_TO_IGNORE = [
r'warning: \d+ warnings? emitted',
r'warning: unused import',
r'warning: field.+ never read',
r'warning: variant.+ never constructed',
r'warning: method.+ never used',
r'warning: associated function.+ never used',
]
SHOULD_IGNORE_PATTERN = re.compile('(' + ')|('.join(DIAGNOSTICS_TO_IGNORE) + ')')
def camelcase(arg: str) -> str:
return re.sub(
# Preserve double-underscores and leading underscores,
# but remove single underscores and capitalize the following letter.
r'([^_])_([^_])',
lambda match: match.group(1) + match.group(2).upper(),
arg)
def collect_decls(crate_dir: str, features: Iterable[str] = ()) -> Iterator[str]:
args = [
'cargo',
'rustc',
'-q',
'--profile=check',
'--features', ','.join(features),
'--message-format=short',
'--color=never',
'--',
'-Zunpretty=expanded']
rustc = subprocess.Popen(args, cwd=crate_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(raw_stdout, raw_stderr) = rustc.communicate()
stdout = str(raw_stdout.decode('utf8'))
stderr = str(raw_stderr.decode('utf8'))
had_error = False
for l in stderr.split('\n'):
if l == '':
continue
if SHOULD_IGNORE_PATTERN.search(l):
continue
print(l, file=sys.stderr)
had_error = True
if had_error:
print('Exiting with error')
sys.exit(1)
comment_decl = re.compile(r'\s*///\s*ts: (.+)')
# Note that the doc attribute is sometimes wrapped onto two lines.
attr_decl = re.compile(r'\s*(?:#\[doc\s*=\s*)?"ts: (.+)"\]')
# Make sure /not/ to match arguments with nested parentheses,
# which won't survive textual splitting below.
function_sig = re.compile(r'(.+)\(([^()]*)\): (.+);?')
for line in stdout.split('\n'):
match = comment_decl.match(line) or attr_decl.match(line)
if match is None:
continue
(decl,) = match.groups()
function_match = function_sig.match(decl)
if function_match is None:
# Fix backslash-escaped double-quotes.
yield bytes(decl, 'utf-8').decode('unicode_escape')
continue
(prefix, fn_args, ret_type) = function_match.groups()
ts_ret_type = translate_to_ts(ret_type)
ts_args = []
if '::' in fn_args:
raise Exception(f"Paths are not supported. Use alias for the type of '{fn_args}'")
for (arg_name, arg_type) in split_rust_args(fn_args):
ts_arg_type = translate_to_ts(arg_type)
ts_args.append('%s: %s' % (camelcase(arg_name.strip()), ts_arg_type))
yield '%s(%s): %s;' % (prefix, ', '.join(ts_args), ts_ret_type)
def expand_template(template_file: str, decls: Iterable[str]) -> str:
decls = list(decls)
with open(template_file, 'r') as f:
contents = f.read()
# Rewrite from function syntax to property syntax to take advantage of
# https://www.typescriptlang.org/tsconfig/#strictFunctionTypes.
contents = contents.replace('NATIVE_FNS;', '\n '.join(
x.removeprefix('export function ')
.replace('(', ': (', 1)
.replace('):', ') =>') for x in decls if x.startswith('export function ')
))
contents = contents.replace('NATIVE_FN_NAMES', ''.join(
'\n ' + x.removeprefix('export function ').split('(')[0] + ','
for x in decls if x.startswith('export function ')
) + '\n')
contents = contents.replace('NATIVE_TYPES;', '\n'.join(
'export ' + x.removeprefix('export ') for x in decls if not x.startswith('export function ')
))
return contents
def verify_contents(expected_output_file: str, expected_contents: str) -> None:
with open(expected_output_file) as fh:
current_contents = fh.readlines()
diff = difflib.unified_diff(current_contents, expected_contents.splitlines(keepends=True))
first_line = next(diff, None)
if first_line:
sys.stdout.write(first_line)
sys.stdout.writelines(diff)
sys.exit(f'error: {expected_output_file} not up to date; re-run {sys.argv[0]}!')
Crate = collections.namedtuple('Crate', ['path', 'features'], defaults=[()])
def convert_to_typescript(rust_crates: Iterable[Crate], ts_in_path: str, ts_out_path: str, verify: bool) -> None:
decls = itertools.chain.from_iterable(collect_decls(crate.path, crate.features) for crate in rust_crates)
contents = expand_template(ts_in_path, decls)
if not os.access(ts_out_path, os.F_OK):
raise Exception(f"Didn't find {ts_out_path} where it was expected")
if not verify:
with open(ts_out_path, 'w') as fh:
fh.write(contents)
else:
verify_contents(ts_out_path, contents)
def main() -> None:
args = parse_args()
our_abs_dir = os.path.dirname(os.path.realpath(__file__))
output_file_name = 'Native.ts'
convert_to_typescript(
rust_crates=[
Crate(path=os.path.join(our_abs_dir, '..')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'types'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'testing'), features=('node', 'signal-media')),
],
ts_in_path=os.path.join(our_abs_dir, output_file_name + '.in'),
ts_out_path=os.path.join(our_abs_dir, '..', '..', '..', '..', 'node', 'ts', output_file_name),
verify=args.verify,
)
if __name__ == '__main__':
main()