RFC-038: Правила завершения операторов и переноса строк
Резюме
Определяет правила завершения операторов в YaoXiang: перенос строки — основная граница, а закрытие скобок, бинарный оператор в конце строки, точка . в начале строки (цепочечное продолжение) — явные исключения продолжения; ( и [ в начале строки никогда не сливаются с предыдущим оператором. ; сохраняется как явный разделитель (несколько операторов в одной строке).
Данный RFC также устраняет нормативный пробел в syntax.md, где символ завершения оператора никогда не был определён, и исправляет известный дефект парсера, при котором ( [ . в начале строки поглощались как постфикс предыдущего оператора.
Мотивация
Зачем нужна эта функциональность?
; в YaoXiang полностью необязателен (14 вызовов skip(Semicolon) в парсере), но перенос строки никогда не определялся как граница операторов: лексер отбрасывает переводы строк, а единственный критерий границы операторов в парсере — «естественная остановка цикла выражений Pratt», то есть «может ли следующий токен продолжить выражение». Это приводит к:
- Поведенческому противоречию: после перевода строки identifier/литерал → нормальное завершение; после перевода строки
([.→ поглощение как постфикса предыдущего выражения (вызов/индексация/доступ к полю) - Молчаливое разрушение допустимых операторов: деструктуризация
(c, d) = (3, 4)превращается в1(c, d) = (3, 4)с вводящей в заблуждение ошибкой E1001;f()(2),1[99]даже не выдают ошибки — оператор просто исчезает - Нормативный пробел: в
syntax.md§2.9Block ::= '{' Stmt* Expr? '}'разделитель между Stmt не определён; в §1.2 в таблице разделителей только( ) { } ,
Текущие проблемы
| Код в блоке | Текущий AST | Результат |
|---|---|---|
x = 1 ⏎ (c, d) = (3, 4) | Assign x = BinOp(Assign, Call(1,[c,d]), Tuple) | E1001 указывает на невинный c |
x = f() ⏎ (2) | Call(Call(f),[2]) | Молча (оператор исчезает) |
x = 1 ⏎ [99] | Index(1,99) | Молча |
x = 1 ⏎ .println("hi") | Call(Field(1),["hi"]) | E1053 |
x = 1 + ⏎ 2 | BinOp(Add) | ✅ Допустимо (но без нормативного обоснования) |
x = (1, ⏎ 2) | Tuple | ✅ Допустимо (но без нормативного обоснования) |
Предложение
Основной дизайн
Главное правило: перевод строки завершает оператор.
Исключения продолжения (три группы, все с прецедентами в мейнстрим-языках):
| # | Исключение | Правило | Прецедент |
|---|---|---|---|
| 1 | Незакрытая скобка | Когда глубина ( [ { > 0, перевод строки не завершает (неявное продолжение) | Python/Scala |
| 2 | Бинарный оператор в конце строки | Строка оканчивается бинарным оператором → продолжение | Swift/Scala |
| 3 | Цепочечное продолжение . в начале строки | . в начале строки и предыдущая строка оканчивается на identifier/)/] → продолжение | Swift |
Никогда не сливаются (урок самой известной проблемы JS):
(и[в начале строки → всегда начинают новый оператор. Известная ловушка JS (a\n(b)→ вызовa(b)) особенно опасна в YaoXiang: деструктуризация кортежей(a, b) = ..., spawn, литералы кортежей — все начинаются со скобок.- Бинарные/унарные операторы в начале строки (
+-*и т.д.) → начинают новый оператор. Продолжение через оператор в конце строки уже покрывает типичные стили переноса; продолжение через оператор в начале строки (лидирующие операторы Scala 3) вносит неоднозначность между унарными и инфиксными операторами — не принято. Для переноса используйте скобки.
; сохраняется: явный разделитель, используемый для нескольких операторов в одной строке (как в Kotlin/Swift).
Примеры
// Перевод строки завершает оператор (подавляющее большинство кода)
a = 1
b = 2
// Продолжение: бинарный оператор в конце строки
total = a +
b + c
// Продолжение: незакрытая скобка
t = (1,
2)
io.println(
"hi")
// Продолжение: . в начале строки (цепочечный вызов)
result = list.map(x => x * 2)
.filter(x => x > 10)
.sum()
// Никогда не сливаются: ( в начале строки — отдельный оператор деструктуризации
x = f()
(c, d) = (3, 4) // ✅ деструктуризация, а не f()(c, d)
// Никогда не сливаются: [ в начале строки — отдельный список
x = 1
[1, 2, 3] // ✅ отдельный оператор-выражение
// Несколько операторов в одной строке: точка с запятой
a = 1; b = 2Изменения синтаксиса
После syntax.md §2.9 добавляется:
StatementTerminator ::= ';' | Newline (за исключением указанных ниже исключений продолжения)
Исключения продолжения (перевод строки не завершает):
- глубина '(' '[' '{' > 0
- бинарный оператор в конце строки
- '.' в начале строки, и предыдущая строка оканчивается на Identifier | ')' | ']'
Никогда не сливаются: '(' '[' в начале строки всегда начинают новый оператор| Раньше | После |
|---|---|
Block ::= '{' Stmt* Expr? '}' (без определения разделителя) | Block ::= '{' (Stmt StatementTerminator)* Expr? '}' |
Перевод строки без статуса, ( [ . в начале строки поглощались | Перевод строки завершает; ( [ в начале строки никогда не сливаются; . в начале строки — явное продолжение |
; необязателен, но семантика размыта | ; = явный разделитель (несколько операторов в строке), после ; может идти перевод строки |
Детальный дизайн
Определение завершения оператора (правила парсера)
В цикле выражений Pratt перед применением постфиксных операторов (вызов (, индексация [, доступ к полю .) выполняется проверка:
continuation(prev_expr, op_token) =
prev_expr строка_окончания == op_token строка_начала // одна строка: нормальный постфикс
|| (op_token == '.' и prev_expr оканчивается на Identifier/')'/']') // . в начале строки — цепочка
|| глубина_скобок > 0 // внутри незакрытых скобокНе выполняется → выражение завершается, начинается новый оператор. Бинарные инфиксные операторы не участвуют в построчной проверке (оператор в конце строки = продолжение, что естественно корректно).
Поверхность воздействия на синтаксис
- В ветке постфиксов
parse_expressionдобавляется проверка номера строки (Span уже несёт номер строки, изменения потока токенов лексера не требуются) - В местах разбора операторов потребляются
;и границы новой строки (семантикаskip(Semicolon)сохраняется) - Закрывающая
}блока и EOF естественным образом завершают оператор, дополнительной обработки не требуется - Не вводится токен NEWLINE (вариант B, см. альтернативы) — номеров строк в Span достаточно, изменения минимальны
Изменения в компиляторе
| Компонент | Изменения |
|---|---|
src/frontend/core/parser/parser_state.rs | Проверка строк для постфиксных операторов в parse_expression |
src/frontend/core/parser/statements/*.rs | Тонкая корректировка логики потребления границ операторов |
docs/src/reference/language-spec/syntax.md | После §2.9 добавляется раздел «Правила завершения операторов» |
| Тесты | tests/yaoxiang/01-syntax/ — новые тесты перевода строки/продолжения; 06-compile-errors/ — регрессии для поглощения в начале строки |
Обратная совместимость
- Подавляющее большинство существующего кода не требует изменений: строки, начинающиеся с identifier/литерала, переносы через оператор в конце строки, переносы внутри скобок — всё соответствует текущему поведению
- Точки изменения поведения (все в направлении исправления ошибок):
- Межстрочное поглощение
f()(2)/1[99]/1.println()меняется с «молчание/ошибка» на «два независимых оператора» — корректная семантика .в начале строки меняется с «поглощение (ошибка)» на «явное цепочечное продолжение» — новая функциональность
- Межстрочное поглощение
- Риск: если существует допустимый код, зависящий от «межстрочного поглощения» (например,
f()\n(2)как вызов возвращённой функции), он станет двумя операторамиf()+(2). Такой код изначально семантически некорректен или крайне редок, требует подтверждения при ревью RFC
Компромиссы
Преимущества
- Мало правил и они интуитивны: два главных правила + три группы исключений, все проверены практикой мейнстрим-языков
- Устранение молчаливых ошибок:
f()(2),1[99]и т.п. больше не поглощаются молча, границы операторов предсказуемы - Удобство цепочечных вызовов: продолжение через
.в начале строки соответствует стандарту Swift/индустрии - Нулевые изменения в лексере: схема с номерами строк Span минимально инвазивна
Недостатки
- Продолжение через бинарный оператор в начале строки не поддерживается (стиль Scala 3) — для переноса нужны скобки, некоторые стили ограничены
- Определение цепочечного продолжения
.в начале строки зависит от «предыдущая строка оканчивается на identifier/)/]», правило требует документирования
Альтернативы
| Вариант | Описание | Почему не выбран |
|---|---|---|
| A. Осведомлённость о Span-номерах строк (данное предложение) | Постфиксные операторы парсера проверяют номера строк | ✅ Принят: минимальные изменения, ясные правила |
| B. Автоматическая вставка точки с запятой в стиле Go | Конечный автомат nlsemi в лексере, вставка ; после определённых токенов в конце строки | Требует } в конце строки, запрещает ./операторы в начале строки, жертвует цепочечным стилем; не соответствует свободному стилю YaoXiang |
| C. Обязательная точка с запятой | Каждый оператор обязан иметь ; | Противоречит существующей экосистеме с необязательной ; и текущим тестовым файлам |
| D. Сохранение статус-кво | Без правил, на основе токенов | Известные дефекты сохраняются, молчаливое исчезновение операторов |
Стратегия реализации
Зависимости
- Внешних зависимостей нет
- Ортогонален RFC-010 (унифицированный синтаксис типов): завершение операторов — правило уровня парсера, не затрагивает грамматику типов
Риски
- Граница между «цепочечным
.в начале строки» и «.в начале строки как самостоятельный оператор»:.foo()не может быть самостоятельным оператором (.не является допустимым началом оператора), поэтому неоднозначности в определении продолжения нет - Существующие тесты, зависящие от поведения поглощения, требуют проверки (ожидается отсутствие — все случаи поглощения являются багами)
Открытые вопросы
- [ ] Стоит ли поддерживать продолжение через бинарный оператор в начале строки (стиль Scala 3)? (@ChenXu233: склоняется к неподдержке, достаточно обёртывания в скобки)
- [ ] Эквивалентен ли перевод строки после
;пустому оператору? Текущийskip(Semicolon)парсера естественно продолжается после перевода строки, специальной обработки не требуется - [ ] Стоит ли ввести «предупреждение о неиспользованном результате выражения» (стиль Swift) в качестве дополнительной защиты от поглощения? Обсудить в отдельном RFC
Приложение A: Сравнительное исследование языков
| Язык | Подход | ( в начале строки | Цепочечный . | Оператор в начале строки | Оценка |
|---|---|---|---|---|---|
| Go | Автоматическая вставка ; в лексере (2 правила) | ✅ Безопасно | ❌ Запрещено | ❌ Запрещено | Самый детерминированный, жертвует цепочечным стилем |
| JavaScript | ASI: 3 правила + restricted productions | ❌ Известная ловушка | Допустимо | ⚠️ Ловушка | Отрицательный пример (a\n(b) → вызов) |
| Python | Жёсткая граница NEWLINE + неявное продолжение в скобках | ✅ Безопасно | ❌ Нужны скобки | ❌ Нужны скобки | Самый предсказуемый, самое слабое продолжение |
| Kotlin | SEMI = ; или перевод строки | ✅ Безопасно | ✅ | ❌ (ловушка trailing lambda) | Хорошо, правила глубоко спрятаны |
| Swift | Перевод строки завершает + правила пробелов | ✅ Безопасно | ✅ | ✅ Защита пробелами | Ближе всего к данному предложению |
| Scala 3 | nl-токен + региональные правила + лидирующие операторы | ✅ | ✅ | ✅ | Самый полный, но самый сложный, избыточно инженерный |
Данное предложение = детерминированность Go × продолжение в скобках Python × цепочечный . в начале строки из Swift. Swift ближе всего к идеалу; Scala 3 наиболее полон, но правила слишком тяжелы — для человека важно не максимальное покрытие правил, а малое число правил, каждое из которых интуитивно.
