flang — язык с доказуемым завершением Инструмент: команды flang и контракт вывода
0%

Инструмент: команды flang и контракт вывода

Инструмент: команды 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 лучше, чем чтение файла из случайного каталога.

Такие места в репозитории попадаются часто: решение принято, альтернатива названа, причина отказа записана рядом.

Дальше — глава «Синтаксис по факту», синтаксис по фактическим файлам.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше