Build time

Теперь про "тулзы работают прямо с кодом". Более точная формулировка была бы такая "тулзы, работающие с кодом, сами являются частью кода".

Например, как работают линтеры в других языках? Обычно это внешняя программа: go - golangci-lint, cpp - pvs-studio, rust - cargo check, python - mypy/black, bash - shellcheck и тд. В общем это внешняя программа, которая к проекту не имеет никакого отношения и ее нужно устанавливать и менеджить отдельно. tool директивы облегчают это, но не сильно.

Как работают форматтеры? Также внешняя программа, в go же есть встроенный go fmt, но есть и внешние, я предпочитаю gofumpt.

Что используется для сборки проекта? Не компиляции проекта его компилятором/интерпретатором, а именно сложные сборки, требующие внешних библиотек, кодгена, статик проверок и тд и тп? Тут все очень плохо, java - gradle/maven, в мире системного и не только программирования Cmake/Makefile/Ninja, python - pip/uv и тд. Почему то это внешний бинарник, иногда даже не поставляемый официально, имеющй свой язык, свой конфиг, свою семантику и тд.

Все эти примеры работают примерно по такой схеме: есть проект, есть конфиг и есть внешний бинарник, который берет конфиг, проект и что-то с проектом делает. У меня встает закономерный вопрос: почему это все делает именно бинарник? Почему нельзя сделать либу, и в проекте одну из точек входа сделать запуск этой библиотеки?

Какие плюсы это несет:

  • тулзы напрямую как зависимости вендорятся в проекте. Можно включать их отдельно, как devDependencies, хотя кажется, что LTO и так должен их отрезать. Остается только юзкейс скачивания транзитивных зависимостей.
  • конфиг не обязан быть теперь файлом, достаточно в либу тулзы передать нужное значение типа конфига, а откуда оно пришло, из файла, или из энва, или захардкожено в коде, уже не важно.
  • как следствие, конфиги перестают быть нужны, по сути, энтрипоинты, запускающие либу, и есть конфиги, но теперь для них работает вся экосистема языка: форматирование, проверка типов, версионирование и тд.

Еще упомяну поинт, что такой подход позволяет запускать произвольный код при запуске тулзы. Ну, во-первых, вы все еще должны следить, какие зависимости вы используете. Во-вторых, кто вам гарантирует, что скачаный бинарник линтера не заменит вам весь код на какую нибудь пропаганду, при обновлении на очередную версию, из-за того, что ему не понравился язык локали?

Теперь, почему жс здесь преуспевает, хоть и с нюансами. Конфиги типо tsconfig.json относятся к "тулзам старого типа": отдельный бинарник запускается и че то делает, а на чем он написан, то ли на ts, то ли на go, нас уже не парит, нас парит, что этот бинарник надо качать. Другие же конфига, вида

import {sosal, Config} from "vite";

export default {
  sosal,
} as Config;

Я воспринимаю, как недописаные скрипты, типо:

import {sosal, run} from "vite";

run({
  sosal,
});

Просто вместо этого скрипта, по инерции мы симулируем запуск бинарника через npm run vite, что по сути и запускает указанный скрипт.

Подход, где конфиг - код, открывает много возможностей, которые недоутилизируются, из примеров:

  • паттерны игнора линтеров форматтеров не обязаны быть "glob"-стрингой. Я могу передать список файлов напрямую, можете параметризовать ее глоб фильтром, или я сам отфильтрую их через свою глоб либу или вообще руками.
  • переиспользование данных между конфигами, вместо кринжовых "import" или "use" с указанием файлов, мы напрямую переиспользуем код.
  • dev/prod конфиги это просто конфиги, вычисляющиеся в зависимости от условной CI=1 переменной, не два отдельных файла, которые надо вместе обновлять. Хотя, если вы хотите, вы все еще можете так делать.

Несмотря на это, js тулинг хотя бы один шаг сделал правильно, что конфиги тулзы это код на js, что, как минимум, позволяет писать плагины и расшиирять тулзы через тот же js код в том же проекте.

Возможно все это напоминает тулзы типо webpack/parcel/vite/rollup/gulp/etc.js, и, я не знаю насколько они конфигурируемы, но как минимум, это уже шажочек в нужную сторону, чего в других языках просто нет.

Из фриковых разработок нового времени, как по мне, есть более зрелые и правильные подходы.

  • zig buildtime - в проекте добавляем файл build.zig, который содержит функцию fn build(b *build.B) аля местный fn main(), но дающий доступ к параметрам билда через аргумент b. Что с этим делать - ваша свобода, можете сделать флаги оптимизации, размера, прогонять кодген перед компиляцией, проверки, форматирования, линтеры, билдтайм вычисления, проверки и установки сишных и любых других зависимостей. Все под вашим контролем. Вы можете даже поставлять python и скрипты на нем, если не нашли других способов запустить какой-нибудь youtube-dl, все это явно будет прописано в коде, можно тестировать и тд.
  • Другой пример еще более закрытый и недоступный для простых смертных, это Jai. В нем все несколько проще и несколько сложнее. Есть compiler директивы, из которых нам интересен #run. Эта директива запускает указанную функцию в build time, сама функция - обычная функция, разве что требующая всех аргументов в build time. Что вы можете с этим сделать: хоть требовать проходить вашу игру, чтобы скомпилировать программу, хоть проверять файлы уровней на проходимость, возможности безграничны. Я бы, возможно, предпочел часть таких проверок делать все еще на уровне типов, но понимаю, что писать доказательство существования пути на зависимых типах доступно не всем, поэтому проще написать императивный код, с поиском пути и поднятием ошибки. Другие же операции: минификация, бандлинг ресурсов, компайл тайм вычисления и тд, все еще не выражаются через

Как интерлюдия, да, есть lisp макросы, сотню лет в обед. Но

  • с первого же появления, макросы есть отдельная сущность, нельзя использовать макрос как функцию или функцию как макрос
  • соответственно для них нужны отдельные танцы с экспандами, квотами и квазиквотами
  • в теории, процедурные макросы rust-а, которые пародируют макросы лиспа, в теории могут делать сайд эффекты, но, учитывая сложность языка и отсутствие типов для макросов, кажется, что это не взлетит
  • есть конечно $vau, который унифицирует функции и макросы, но семантика этого языка концептуально ограничивает его перфоманс, так что это тоже будет адекватно распространить