Как я писал генератор TypeScript-биндингов для Tauri

от автора

Всё началось с бага, на который я убил вечер. В Tauri фронтенд вызывает Rust-команды так:

const user = await invoke("get_user", { userId: 42 });

Строка с именем команды, объект с аргументами, никакой проверки типов. У меня в Rust аргумент назывался user_id, и я честно передал user_id с фронта. В рантайме прилетело что-то вроде invalid args 'userId' for command 'get_user' — Tauri по умолчанию переименовывает snake_case-аргументы в camelCase. Это написано в документации, я это даже знал. Но знание, которое не проверяет компилятор, — не знание, а примета.

Дальше по классике: возвращаемый тип у invoke — any, а о переименованном поле в структуре фронт узнаёт уже от пользователей. Захотелось, чтобы всё это генерировалось из Rust-кода само.

Почему не готовые решения

ts-rs умеет экспортировать типы, но требует #[derive(TS)] на каждой структуре и ничего не знает про команды. tauri-specta закрывает и команды, но просит зарегистрировать хендлеры через свои макросы и живёт внутри кода приложения. Оба варианта рабочие, и если они вам подходят — берите их.

Мне хотелось другого: CLI, который натравливаешь на существующий проект и получаешь готовые файлы. Без аннотаций и без единой правки в Rust-коде. Так появился tauri-ts-generator. Вот что он делает:

#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct User {    pub id: u64,    pub display_name: String,}#[tauri::command]async fn get_user(user_id: u64, state: State<'_, AppState>) -> Result<User, String> {    // ...}

превращается в

// types.tsexport interface User {  id: number;  displayName: string;}// commands.tsexport async function getUser(userId: number): Promise<User> {  return invoke<User>("get_user", { userId });}

State исчез из сигнатуры — его инжектит сам Tauri, фронт его не передаёт. Первый же баг-репорт в трекере был, кстати, ровно про это: если объявить State через type-алиас, он просачивался в TS-типы. Result<User, String> стал Promise<User>, ошибка и так прилетит как reject. А display_name стал displayName, потому что на структуре висит rename_all = "camelCase" — генератор смотрит на serde-атрибуты, а не на имена полей в коде.

Как это устроено внутри

Никакой магии с компилятором: генератор парсит исходники через syn. Сканер обходит src-tauri, парсер вытаскивает функции с #[tauri::command] и все структуры, енумы и алиасы, резолвер разруливает модули и use-алиасы, генератор пишет два файла. Примерно 12 тысяч строк Rust. Резолвер, кстати, в какой-то момент разросся до 1378 строк в одном файле, и его пришлось пилить на подмодули — модульная система Rust со всеми её pub use, реэкспортами и use crate as alias оказалась самой неблагодарной частью задачи.

А вот что пришлось научиться понимать про serde (это реальные коммиты, не теоретизирование):

  • #[serde(rename_all)] во всех восьми вариантах, плюс точечный rename на полях;

  • tagged и untagged енумы: #[serde(tag = "type", content = "data")] полностью меняют форму JSON;

  • #[serde(flatten)] — в TS это intersection types;

  • Option<Option<T>>, который serde схлопывает;

  • #[serde(transparent)], newtype- и tuple-структуры;

  • кейс-конверсия с акронимами: serde считает HTTPServer двумя словами, а моя наивная реализация при rename_all = "snake_case" выдавала h_t_t_p_server.

Главный принцип, к которому я пришёл не сразу: генерировать надо не то, как выглядит Rust-код, а то, что serde реально отправит по проводу. Это разные вещи. TS-тип, который красиво повторяет структуру, но расходится с рантайм-JSON хотя бы в одном поле, хуже, чем any: он врёт с уверенным видом.

Война с макросами, или три релиза за один день

Дальше я упёрся в macro_rules!, который штампует десяток однотипных команд. Парсер исходников, естественно, видит макрос и не видит команд.

Ок, думаю, есть же cargo expand — разверну макросы и распарсю результат. Релизю 2.0.1: прогоняю тот же parse_commands по развёрнутому коду. Находит ноль команд.

Полез смотреть в сам expand-вывод (с чего, конечно, стоило начать). Оказалось, #[tauri::command] — процедурный макрос, и при развёртывании он съедает сам себя: в выводе остаётся голая функция без атрибута. А мой парсер фильтровал именно по атрибуту. Я искал маркер, который expand гарантированно уничтожает.

Зато рядом с каждой командой Tauri оставляет след: pub use __cmd__get_user;. Релизю 2.0.2: собираю эти __cmd__-маркеры из use-итемов и матчу обратно к функциям. Работает. Правда, теперь у меня два параллельных обходчика AST с почти одинаковой логикой.

В 2.0.3 объединил их в один walker и заодно обнаружил, что старый рекурсировал во вложенные mod ровно на один уровень, а в impl-блоки не заглядывал вообще. Команда внутри mod a { mod b { ... } } молча терялась. Восемь новых тестов пригвоздили контракт.

Все три релиза вышли 10 мая. Насыщенный был день.

Одну вещь я чинить не стал. Если макро-генерённая команда использует rename_all, это значение восстановить невозможно: код с переименованием генерируется на стороне потребителя, внутри generate_handler!, и в expand-выводе библиотеки его просто нет. Можно было городить эвристику, но я записал это в changelog как known limitation: нужен rename_all — пишите команду руками.

Windows, TOML и \U

Короткая, но любимая. Четыре теста падали только на Windows CI с паникой too few unicode value digits, expected unicode hexadecimal value. Тесты подставляли путь от tempdir в TOML-конфиг:

let config = format!(r#"types_file = "{}""#, dir.path().display());

На Windows путь — это C:\Users\runneradmin\.... А в TOML basic strings (двойные кавычки) бэкслеш — escape-символ, и \U означает начало юникод-эскейпа. Парсер ждёт восемь hex-цифр, получает sers\... и падает. Фикс — одинарные кавычки, literal strings, путь как есть. Один символ в кавычках, зелёный CI.

Тесты

Юнит-тесты на парсер и кейс-конверсию есть, но главную уверенность дают e2e: харнесс собирает во временной папке настоящий мини-проект с Cargo.toml и исходниками, запускает генератор целиком и проверяет выходные файлы. Сейчас тестов 352, и под каждый фикс из истории выше в репозитории лежит e2e, воспроизводящий проблему.

Релизы

Публикация на crates.io — через GitHub Actions по тегу. Локально cargo-release только бампает версию и ставит тег, токена registry на машине нет вообще. Паблиш идемпотентный, и это выстрадано. Релизный ран v2.0.0 опубликовал крейт и упал на следующем шаге, создании GitHub-релиза: 403, токену не хватило прав. А просто перезапустить нельзя — второй заход падал бы уже на «already exists». Отдельно отличился cargo search, через который CI проверял «крейт уже опубликован?»: однажды он вернул пустоту для крейта, который точно есть в индексе, и проверка оказалась хрупче того, от чего защищала. Теперь CI всегда пробует publish и разбирает ответ cargo: «already exists» — не ошибка, едем дальше.

Что в итоге

Генератор живёт на crates.io как tauri-ts-generator, сейчас версия 2.1.0. Внешних PR за время жизни проекта было два. Причём #[ts(optional)] вообще родился из обратной связи: пользователь попросил фичу в issue, а потом сам же прислал PR с доработкой генерации — prop?: T вместо prop: T | undefined. Второй PR принёс поддержку tauri::ipc::Channel<T>, так что стриминг из бэка на фронт теперь тоже типизирован. Чужой pull request в твой пет-проект — отдельное удовольствие.

Из честного: генератор парсит исходники, а не спрашивает компилятор, поэтому всегда найдётся способ его обмануть — алиас на алиас через три крейта, условная компиляция, макросы поверх макросов. Часть таких кейсов закрывает флаг use_cargo_expand, часть закрывать не планирую: предпочитаю простую модель с задокументированными ограничениями.

Код: https://github.com/Dudude-bit/tauri-codegen Крейт: https://crates.io/crates/tauri-ts-generator

Если у вас Tauri-проект и вы тоже устали от строковых invoke — попробуйте. Споткнётся на вашем коде — несите репро в issues.

ссылка на оригинал статьи https://habr.com/ru/articles/1048060/