Инструмент: команды flang и контракт вывода
Язык ставится одной командой:
brew install digitable-lol/tap/flang. Номера версий в разборе ниже — те, что стояли на момент его написания.
Прежде чем разбирать синтаксис, полезно получить инструмент, который скажет,
правы вы или нет. У flang это один файл — flang/bin/flang.mjs, 734 строки,
запускается напрямую из исходников. Рядом с ним появился второй вход,
flang/bin/flang-lsp.mjs, — про него в конце главы.
Установка: два разных пути, и Node нужен только одному
Здесь произошло главное изменение с прошлой редакции этой главы, и начать стоит с него: компилятор языка теперь ставится без Node.
brew install digitable-lol/tap/flang
Работает это потому, что компилятор flang написан на самом flang и печатается в
C: релиз везёт уже напечатанный C99, и всё, что нужно для установки, — это cc
и make. Разбор — в главе
«Ядро FTS на flang», где мы собираем этот
релиз в окружении без Node и получаем нативный бинарник, слинкованный с libc и
libm и больше ни с чем.
Что при этом получаешь и чего не получаешь, стоит развести сразу, потому что это два разных инструмента с одним именем:
| Путь | Требует | Что даёт |
|---|---|---|
brew install …/flang |
cc, make |
компилятор: разбор, типы, тотальность, печать только в C, оболочка flang repl |
сборка из исходников: make -C bootstrap -j8 |
cc, make |
дерево репозитория целиком: интерпретатор, run/test/facts/io, спеки fspec/ |
Причина ровно та, что записана в README: семь бэкендов из восьми, интерпретатор и сам CLI написаны на JavaScript, и на flang их никто не переписывал — для снятия зависимости от Node хватило одной цели. Так что всё, что разбирается в этой главе ниже, — про путь через Node.
Одна строка в этой таблице новая и стоила проекту отдельной поправки. Оболочки
в бинарнике из brew ещё вчера не было: flang_repl.c появился уже после тега
v0.4.4, и брать её было неоткуда. Теперь есть — формула
packaging/homebrew/flang.rb ссылается на v0.4.5, а печать релизного C
просит оболочку явно, одним ключом в scripts/build-release-c.mjs:
const напечатано = emitC(программа, { cli: true, repl: true, maxSteps: 40_000_000, maxDepth: 20_000 })
Второй вход в напечатанном flang_cli.c разбирает argv[1] == "repl" и уходит
в fl_repl_main. То есть язык из brew можно не только запустить, но и
потрогать.
Пакет: собран, выпущен, лежит в реестре
Отдельного пакета flang тогда не было: язык ехал внутри пакета FTS.
Ещё недавно не было и этого — package.json был помечен "private": true, то
есть не публиковался вовсе. Флаг сняли, и теперь в пакете есть запись
"bin": { …, "flang": "./flang/bin/flang.mjs" }
а в список files добавлены flang/bin, flang/src, flang/stdlib,
flang/core, flang/examples и flang/SPEC.md. Тесты, наоборот, из пакета
убрали — их туда уезжало пятьдесят файлов.
Здесь у нас стояло: «обратите внимание, чего в этом списке нет: flang/self;
каталог, где компилятор переписывается на самом языке, в поставку не входит».
Это было неверно, и неверно с самого начала — мы прочитали список вместо того,
чтобы упаковать пакет. В files перед перечислением лежит запись flang
целиком, и она перекрывает все шесть уточнений; уточнения избыточны, а
flang/self попадает в тарбол вместе со всем каталогом:
$ npm pack --dry-run --json
файлов 368, архив 1,35 МБ, распакованный 9,2 МБ
flang/self: 7 файлов, 1312 КБ
(Числа выросли с прошлой редакции ровно на седьмой файл — self/bootstrap/compiler.flang,
шестой слой компилятора.)
Больше миллиона байт из девяти — это написанный на самом языке компилятор
(глава «Ядро FTS на flang»), то есть самая
крупная часть поставки. Отрицательные записи в files работают исправно и
именно так, как задумано: !**/test и !**/*.test.mjs тесты действительно
вырезают — тестовых файлов в тарболе ноль. Но вырезано ровно то, что вырезали
явно, а не то, что не упомянули явно.
Урок ровно тот же, что у всей этой главы: список в манифесте — это намерение,
а состав пакета — результат. Совпадают они только тогда, когда кто-нибудь
запустил npm pack.
Публикация при этом не происходит сама: workflow срабатывает на тег вида v*, а
не на push в main, и причина записана в его шапке — «опубликованную версию
нельзя переиздать под тем же номером, а снять её можно лишь в первые 72 часа и
ценой репутации пакета. Тег — это явное решение человека „вот эту версию
наружу“». Такое решение только что приняли: тег v0.4.0 поставлен и уехал на
origin, аннотация к нему называет состав — «пакет несёт FTS и flang одним
набором: fts, fts-mcp, ftsc, ftsvm, ftspec и flang».
В прошлой редакции здесь стояло, что в реестре пусто, и стояло по факту. Сегодня язык раздаётся не реестром, а тапом, и отвечает он так:
$ brew info digitable-lol/tap/flang
==> digitable-lol/tap/flang: stable 0.6.2
Дорога от тега до раздачи доехала. В прошлой редакции этой главы мы отмечали
разрыв — в раздаче 0.4.1, а в package.json репозитория уже 0.4.2. Разрыв
на момент той сверки закрылся: и раздача, и манифест, и формула Homebrew, и
последний тег в origin говорили 0.4.5. Держать в голове стоит не сам разрыв,
а способ его увидеть: проверять надо не по тегу и не по манифесту, а по тому
месту, откуда инструмент ставится, — командой выше.
Отдельно про flang version. Она по-прежнему отвечает 0.1.0, и это не
опечатка в наших записях, а третье место, живущее своим календарём:
$ node flang/bin/flang.mjs version
0.1.0
Номер языка и номер пакета — разные числа, и их совпадения никто не обещал.
Проверок до публикации workflow ставит себе пять: сверку версии в теге с
версией в package.json, сборку, весь npm test, состав тарбола (тестовых
файлов в нём быть не должно — однажды туда уже уезжало пятьдесят) и запуск
flang check из распакованного архива. Порядок осмысленный: в реестр уходит
только то, что собралось, сошлось и запустилось из своей же поставки.
Но интереснее, что за сутки выяснилось про саму эту дорогу: она была перекрыта в трёх местах подряд, и ни одно нельзя было увидеть, не запустив workflow.
- Файл не проходил разбор выражений. У шага стоял выход
steps.guard.outputs.настроен, а GitHub разбирает идентификатор по[a-zA-Z_][a-zA-Z0-9_-]*. Прогон падал на старте, за ноль секунд, на каждый push в main — не выполнив ни единого шага, поэтому в логах не оставалось ни причины, ни следа: только красный прогон без заданий. - Дальше шаг «проверить, что flang запускается из пакета» падал с
ENOENTна файле, который сам же собирался положить:npm --pack-destinationкаталог не заводит, аmkdirстоял строкой ниже. - Дальше сверка тега с версией умирала с кодом 127, «command not found»:
присваивание было записано как
тег=…, а в именах переменных оболочка допускает только[A-Za-z_][A-Za-z0-9_]*— и разбирала строку не как присваивание, а как команду.
Все три починены, и каждая починка оставлена комментарием на месте поломки, а не только в истории коммитов. Общее у них тоже одно, и его стоит назвать прямо: кириллица уместна внутри значений и сообщений и не уместна в именах — переменных оболочки, выходов шага, идентификаторов выражений. Три разных механизма, одно правило.
Поменялось за те же сутки и имя пакета: scope в нём теперь @digitable-lol, а
не @digitable. Организации digitable в реестре у владельца нет — то есть
первая же публикация упёрлась бы не в ошибку сборки, а в отказ по правам, уже
после того, как все пять проверок прошли. Пакет тогда ни разу не публиковался,
поэтому переименование не стоило ничего: перенаправлять нечего, потребителей
старого имени нет.
Поучительно здесь не то, что в конвейере было три ошибки, а то, чем они все
похожи: конвейер, который ни разу не доехал до конца, не отличается от
работающего ничем, кроме прогона. Теперь он доехал — и это ровно то
доказательство, которого не давали ни зелёный npm test, ни поставленный тег.
Для работы с языком, впрочем, ни реестр, ни установка не обязательны — клона достаточно:
git clone https://github.com/digitable-lol/flang.git
cd flang
node flang/bin/flang.mjs check flang/stdlib/lists.flang --pretty
Ни npm install, ни сборки для этого не нужно. Приятная особенность: весь путь
.flang — лексер, парсер, типы, тотальность, интерпретатор, связывание модулей,
кодогенерация — написан на ES-модулях без единой зависимости, поэтому работает
на голом Node. В README.md заявлена версия 20 или новее; наши прогоны шли на
Node 24.18.0.
Восемь команд
Справка выводится без аргументов:
Использование:
flang check <файл> [--pretty]
flang run <файл> --function «Имя» --args '{"поле": 1}' [--pretty]
flang test <файл> [--pretty]
flang facts <файл> --facts факты.json --claims '["…"]' [--steps N] [--pretty]
flang ast <файл> [--pretty]
flang emit <файл> --target <язык> [--out каталог] [--cli|--no-cli]
[--index-base 0|1] [--max-depth N] [--max-steps N] [--pretty]
flang io <файл> [--plan «Имя»] [--seed N] [--in-dir] [--max-orders N]
[--no-read] [--no-write] [--no-net] [--no-clock] [--no-random]
flang repl [файл] [--max-steps N] [--max-depth N]
flang version
Команд стало восемь: с прошлой редакции прибавились io и repl. Обе разобраны
ниже; io — самая содержательная, потому что за ней стоит решение о том, как
чистый язык вообще может читать файл.
Замечание для тех, кто читает flang/SPEC.md: в разделе 6 указан набор
check | run | test | ast | fmt. Команды fmt не существует — CLI отвечает
unknown command 'fmt'. Зато существуют facts, emit, io и repl, которых
в том разделе нет. Спецификация отстаёт от кода; когда они расходятся, верить
надо коду.
check
Разбор, типы и тотальность за один проход:
$ node flang/bin/flang.mjs check flang/stdlib/lists.flang --pretty
{
"valid": true,
"module": "Списки",
"functions": [
{ "name": "Длина", "total": true },
{ "name": "Приписать в начало", "total": true },
…
],
"types": [],
"diagnostics": []
}
Поле total здесь — не то, что написал автор, а то, что доказал анализ.
test
Исполняет примеры, лежащие внутри функций:
$ node flang/bin/flang.mjs test flang/stdlib/lists.flang --pretty
{
"valid": true,
"total": 45,
"passed": 45,
"failed": 0,
"results": [ … ]
}
Отдельно отметим: примеры, возвращающие списки, сравниваются структурно и
проходят. В flang/examples/leetcode/index.json записано обратное — что
сравнение идёт через Object.is и любой пример со списком проваливается. На
момент нашей проверки это уже неверно: 45 примеров lists.flang, среди которых
множество списочных, проходят все. Файл index.json местами описывает состояние
языка на несколько часов раньше — см. главу «Чего в языке пока нет».
run
Вызов одной функции:
$ node flang/bin/flang.mjs run проба.flang \
--function "Сумма больше порога" --args '{"суммы":[1,2],"порог":10}' --pretty
{
"function": "Сумма больше порога",
"args": { "суммы": [1, 2], "порог": 10 },
"result": false
}
--args принимает объект «имя параметра → значение». Для функции, пришедшей из
FTS-утилиты — то есть с единственным параметром-записью, — CLI умеет обернуть
плоский объект сам: это сделано в bindArguments, чтобы форма --args не
менялась при переходе с FTS.
facts
Встраиваемый факт-чекинг. Утверждение — это строка вида
<терм> <оператор> <терм>, и грамматика термов узкая (flang/src/factcheck.mjs):
факт «Имя»— значение факта;поле «Имя» факта «Имя»— поле записи из фактов;«Функция» от факт1, факт2— вызов с фактами в аргументах;- литерал: число,
да,нет,ничто, строка в кавычках.
Операторы — равно, не равно, больше, меньше, не больше, не меньше и
их английские аналоги.
$ node flang/bin/flang.mjs facts факты.flang --facts факты.json \
--claims '["«Сумма больше порога» от суммы, порог равно да"]' --pretty
{
"ok": true,
"results": [ {
"claim": "«Сумма больше порога» от суммы, порог равно да",
"holds": true,
"why": "«Сумма больше порога» от факта «суммы», факта «порог» = да;
требование «равно да» выполнено",
"status": "verified"
} ]
}
В ответе есть массив steps — след разбора, проверки тотальности и вычисления.
Это не отладочный вывод, а часть смысла режима: ответ должен быть
воспроизводимым и объяснимым.
Ненулевой код возврата при ok: false — чтобы CI мог на нём падать. Но сам JSON
уходит в stdout, а не в stderr: опровергнутое утверждение — это результат
работы, а не сбой инструмента.
ast
Печатает программу в канонический JSON — тот самый контракт между слоями из раздела 5 спецификации. Полезно, когда надо понять, во что превратилась конструкция; разбор формата в главе «Синтаксис по факту».
emit
Печать в целевой язык. Разобрана отдельно в главе «Кодогенерация»;
здесь достаточно знать, что целей восемь и что список берётся не из константы в
CLI, а из содержимого каталога flang/src/emit:
$ node flang/bin/flang.mjs emit проба.flang --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, csharp, elixir, go, java, js, python, rust", …}
Устройство «список целей — это содержимое каталога» окупилось дважды: сперва
Rust и Python появились как два новых файла рядом с c.mjs, go.mjs и
js.mjs, потом тем же способом легли java.mjs, csharp.mjs и elixir.mjs, —
и ни справка, ни диагностика неизвестной цели правки не потребовали ни разу.
Ключ --max-steps в этом списке — самый новый: он появился вместе с лимитом
шагов в бэкенде C, и с ним печатаемая программа получает тот же предел, что
интерпретатор, а не жёстко зашитый миллион
(глава «Кодогенерация»).
io
Самая новая команда и единственная, которая трогает внешний мир. Устройство разобрано в главе «Синтаксис по факту», в разделе про поручения и план; здесь важно, что делает именно CLI.
Программа объявляет план — три строки, называющие состояние, начальную
функцию и функцию шага. flang io заводит цикл: зовёт шаг, получает описание
поручения, исполняет его сам и возвращает отклик обратно в программу. Вот
прогон плана, который читает файл и пишет отчёт:
$ node flang/bin/flang.mjs io перепись.flang --in-dir --pretty
{
"plan": "Перепись файла",
"result": "готово",
"orders": 2,
"log": [
{ "поручение": { "variant": "Прочитать файл",
"fields": { "путь": "адрес.txt" } },
"отклик": { "variant": "Прочитано",
"fields": { "содержимое": "http://example.invalid/a\n" } } },
{ "поручение": { "variant": "Записать файл",
"fields": { "путь": "отчёт.txt",
"содержимое": "прочитано: http://example.invalid/a\n" } },
"отклик": { "variant": "Записано", "fields": { "сколько": 36 } } }
]
}
Обратите внимание на log: это журнал выданных поручений и полученных
откликов, то есть полная запись того, что программа попросила у мира и что
мир ответил. Не трассировка для отладки, а часть результата.
Полномочия хозяина сужаются ключами. Запрет — не исключение, а отклик, и это
решение записано в flang/src/host/node.mjs прямым текстом: «Не исключением —
откликом: программа обязана уметь его встретить».
$ node flang/bin/flang.mjs io перепись.flang --in-dir --no-read --pretty
{
"error": "хозяину запрещено читать файлы",
"diagnostics": [ { "code": "FLANG_IO_DENIED",
"message": "хозяину запрещено читать файлы", … } ]
}
Пять ключей — --no-read, --no-write, --no-net, --no-clock,
--no-random — плюс --in-dir, который запирает пути в каталоге файла, и
--seed N, от которого зависит поручение «Случайное число». Последнее не
украшение: прогон, который нельзя повторить, нельзя и проверить.
repl
Оболочка. Единственная команда с человеческим выводом вместо JSON — так и написано в справке.
$ node flang/bin/flang.mjs repl
Оболочка flang.
Объявление вводится в несколько строк; пустая строка заканчивает ввод:
…
Объявление проверяется той же дорогой, что и «flang check»: разбор, типы,
завершаемость. Не прошедшее проверку в сессию не попадает.
Команды (строка с точки — точкой не может начаться ни одна конструкция языка):
.помощь эта справка
.объявления что объявлено в сессии
.исходник исходник сессии целиком
.сохранить <файл> записать исходник сессии в файл
.загрузить <файл> добавить объявления из файла .flang
.сбросить забыть всё объявленное
.выход закончить работу
По-английски: .help .list .source .save .load .reset .quit
Две детали стоят внимания, потому что обе — решения, а не удобства.
Первая: команда начинается с точки, и это не стиль, а разбор. В справке сказано прямо — «точкой не может начаться ни одна конструкция языка», поэтому строка с точки не может быть спутана с программой. Никакого экранирования, никакого режима.
Вторая: объявление проходит ту же проверку, что flang check. Оболочка не
«пробует выполнить», а разбирает, проверяет типы и доказывает завершаемость — и
не прошедшее в сессию не попадает. У языка, где тотальная ставится анализом, а
не автором, иначе и нельзя: оболочка с ослабленной проверкой показывала бы
другой язык.
Модули подключаются не командой, а строкой языка: использует «Списки» из "flang/stdlib/lists.flang".
Языковой сервер: та же проверка в буфере редактора
Рядом с CLI появился второй вход — flang/bin/flang-lsp.mjs, 174 строки, и
flang/src/lsp.mjs, 892 строки. В package.json он объявлен отдельной
командой:
"bin": { "flang": "./flang/bin/flang.mjs", "flang-lsp": "./flang/bin/flang-lsp.mjs", … }
Это закрывает пункт, который в главе «Чего в языке пока нет»
стоял среди недостающего вокруг языка. Оговорка при этом остаётся: сервер
языка — не форматирование и не линтер, fmt по-прежнему нет.
Контракт вывода
Он унаследован от FTS и соблюдается всеми командами:
- результат — JSON в stdout;
- диагностика — JSON в stderr;
- отказ — ненулевой код возврата, причём
2для ошибки вызова (не хватает ключа, кривой JSON) и1для ошибки модели или вычисления.
Смысл в том, что вокруг FTS уже есть скрипты, агенты и CI, умеющие читать этот
формат. Из шапки bin/flang.mjs: «Расхождение в контракте вывода стоило бы
дороже, чем любая „улучшенная“ подача».
Практическое следствие: --pretty нужен только человеку. В пайплайне вывод
идёт одной строкой и режется jq.
Три вида входного файла
CLI принимает не только .flang:
| Расширение | Что происходит |
|---|---|
.flang, .fl |
разбор parser.mjs, затем связывание модулей link.mjs |
.json |
готовый AST, читается как есть |
.fts |
модель FTS, переводится мостом compat.mjs |
Если расширения нет, формат угадывается по содержимому: текст, начинающийся с
{, читается как JSON, остальное — как FTS.
Важная оговорка про .fts. Мост подгружает собранное ядро FTS
(dist/src/index.js), поэтому без npm install && npm run build эта ветка не
работает:
$ # на свежем клоне, до npm install && npm run build
$ node flang/bin/flang.mjs check examples/utilities/discount.fts
{"error":"Cannot find module '…/dist/src/index.js' imported from …/flang/bin/flang.mjs",
"diagnostics":[{"code":"ERR_MODULE_NOT_FOUND", …}]}
Каталог dist/ под контролем версий не лежит, так что в свежем клоне его нет по
определению; импорт стоит в flang/bin/flang.mjs, строка 288. Диагностика
честная, но не подсказывает решение. Ветка .flang от сборки не зависит вовсе.
Про stdin
Файл - означает стандартный ввод, и одна деталь здесь сделана намеренно: для
stdin связывание модулей не запускается. Причина в комментарии:
Со стандартного ввода нет каталога, относительно которого разрешается
использует … из "…", поэтому связывание для него не запускаем: честныйFLANG_UNKNOWN_NAMEлучше, чем чтение файла из случайного каталога.
Такие места в репозитории попадаются часто: решение принято, альтернатива названа, причина отказа записана рядом.
Дальше — глава «Синтаксис по факту», синтаксис по фактическим файлам.