Skip to content

RFC-038: Правила завершения операторов и переноса строк ​

Резюме ​

Определяет правила завершения операторов в YaoXiang: перенос строки — основная граница, а закрытие скобок, бинарный оператор в конце строки, точка . в начале строки (цепочечное продолжение) — явные исключения продолжения; ( и [ в начале строки никогда не сливаются с предыдущим оператором. ; сохраняется как явный разделитель (несколько операторов в одной строке).

Данный RFC также устраняет нормативный пробел в syntax.md, где символ завершения оператора никогда не был определён, и исправляет известный дефект парсера, при котором ( [ . в начале строки поглощались как постфикс предыдущего оператора.

Мотивация ​

Зачем нужна эта функциональность? ​

; в YaoXiang полностью необязателен (14 вызовов skip(Semicolon) в парсере), но перенос строки никогда не определялся как граница операторов: лексер отбрасывает переводы строк, а единственный критерий границы операторов в парсере — «естественная остановка цикла выражений Pratt», то есть «может ли следующий токен продолжить выражение». Это приводит к:

  1. Поведенческому противоречию: после перевода строки identifier/литерал → нормальное завершение; после перевода строки ( [ . → поглощение как постфикса предыдущего выражения (вызов/индексация/доступ к полю)
  2. Молчаливое разрушение допустимых операторов: деструктуризация (c, d) = (3, 4) превращается в 1(c, d) = (3, 4) с вводящей в заблуждение ошибкой E1001; f()(2), 1[99] даже не выдают ошибки — оператор просто исчезает
  3. Нормативный пробел: в syntax.md §2.9 Block ::= '{' 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 + ⏎ 2BinOp(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).

Примеры ​

yaoxiang
// Перевод строки завершает оператор (подавляющее большинство кода)
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 правила)✅ Безопасно❌ Запрещено❌ ЗапрещеноСамый детерминированный, жертвует цепочечным стилем
JavaScriptASI: 3 правила + restricted productions❌ Известная ловушкаДопустимо⚠️ ЛовушкаОтрицательный пример (a\n(b) → вызов)
PythonЖёсткая граница NEWLINE + неявное продолжение в скобках✅ Безопасно❌ Нужны скобки❌ Нужны скобкиСамый предсказуемый, самое слабое продолжение
KotlinSEMI = ; или перевод строки✅ Безопасно✅❌ (ловушка trailing lambda)Хорошо, правила глубоко спрятаны
SwiftПеревод строки завершает + правила пробелов✅ Безопасно✅✅ Защита пробеламиБлиже всего к данному предложению
Scala 3nl-токен + региональные правила + лидирующие операторы✅✅✅Самый полный, но самый сложный, избыточно инженерный

Данное предложение = детерминированность Go × продолжение в скобках Python × цепочечный . в начале строки из Swift. Swift ближе всего к идеалу; Scala 3 наиболее полон, но правила слишком тяжелы — для человека важно не максимальное покрытие правил, а малое число правил, каждое из которых интуитивно.