Skip to content

Commit d5ecad6

Browse files
olwangclaude
andcommitted
feat(parser): binary-operator line continuation (SH-017); correct SH-020
SH-017 proper fix: statement-level expressions now continue across newlines when a line begins with or follows a line ending in an unambiguous binary operator (| & + * / % ^), so wrapped ||/&&/+ chains are valid instead of silently-wrong. Excludes < > - = ! (generics/comparison/unary and the dangling-'let x =' / leading-'!expr' footguns). scan.rs statement_end. Spec §A.1 gains the normative statement-termination + continuation rule (fixing the stale 'not layout-sensitive' claim). Fail fixture -> pass fixture (multiline-operator-continuation.rss). SH-020: corrected — tuple returns + destructuring work (verified); the entry over-claimed a non-existent 'no multi-value return' gap. Withdrawn. Gated: full suite CARGO_EXIT=0 (static 628), self-host parity 551/551. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7b7dfdd commit d5ecad6

5 files changed

Lines changed: 104 additions & 32 deletions

File tree

crates/rsscript/src/syntax/parser/scan.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ fn starts_top_level_item(tokens: &[Token], index: usize) -> bool {
5858
}
5959

6060
pub(super) fn statement_end(tokens: &[Token], start: usize, limit: usize) -> usize {
61-
let line = tokens[start].span.line;
61+
let mut line = tokens[start].span.line;
6262
let mut depth = 0usize;
6363
let mut angle_depth = 0usize;
6464
let mut postfix_continuation_line = None;
@@ -76,6 +76,20 @@ pub(super) fn statement_end(tokens: &[Token], start: usize, limit: usize) -> usi
7676
// `.map(...)` chain.
7777
} else if is_statement_postfix_token(token) {
7878
postfix_continuation_line = Some(token.span.line);
79+
} else if is_continuation_operator(token)
80+
|| tokens
81+
.get(index - 1)
82+
.is_some_and(|prev| is_continuation_operator(prev))
83+
{
84+
// Binary-operator line continuation: a line that *begins* with a
85+
// binary operator (leading style) or *follows* a line ending in
86+
// one (trailing style) continues the current statement's
87+
// expression rather than starting a new statement. Absorb this
88+
// line by advancing the reference line. Only the unambiguous
89+
// operators in `is_continuation_operator` qualify (see there for
90+
// why `<`/`>`/`-`/`=`/`!` are excluded), so a wrapped chain can
91+
// never swallow the start of a genuinely new statement.
92+
line = token.span.line;
7993
} else {
8094
return index;
8195
}
@@ -100,6 +114,28 @@ fn is_statement_postfix_token(token: &Token) -> bool {
100114
token.symbol("?") || token.symbol(".")
101115
}
102116

117+
/// Binary operators that make a statement-level expression continue across a
118+
/// newline (SH-017): `|` `&` `+` `*` `/` `%` `^` (so `||`, `&&`, `+`, `*`, … may
119+
/// wrap). The set is deliberately conservative so a wrapped chain can NEVER
120+
/// absorb the start of a genuinely new statement:
121+
/// - `<` / `>` are excluded (generic brackets / comparison).
122+
/// - `-` is excluded (unary minus can legitimately start a statement).
123+
/// - `=` is excluded (a dangling `let x =` must stay a *malformed statement*, not
124+
/// silently swallow the next line — that would reintroduce the very
125+
/// silent-merge footgun SH-017 fixes).
126+
/// - `!` is excluded (a leading `!expr` is a valid statement start).
127+
/// Consequently `==` / `!=` / `<=` / `>=` / `=` cannot wrap across lines — keep
128+
/// those on one line.
129+
fn is_continuation_operator(token: &Token) -> bool {
130+
token.symbol("|")
131+
|| token.symbol("&")
132+
|| token.symbol("+")
133+
|| token.symbol("*")
134+
|| token.symbol("/")
135+
|| token.symbol("%")
136+
|| token.symbol("^")
137+
}
138+
103139
pub(super) fn find_control_body_open(
104140
tokens: &[Token],
105141
start: usize,

crates/rsscript/tests/fixtures/fail/leading-operator-continuation.rss

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// A statement-level expression may now continue across newlines when a line
2+
// begins with, or ends with, an unambiguous binary operator (SH-017).
3+
4+
// Leading-operator style: continuation lines start with `||`.
5+
fn is_kw_leading(word: read String) -> Bool {
6+
return word == "if" || word == "else"
7+
|| word == "fn"
8+
|| word == "let"
9+
}
10+
11+
// Trailing-operator style: lines end with the operator.
12+
fn sum_trailing(a: Int, b: Int, c: Int) -> Int {
13+
return a +
14+
b +
15+
c
16+
}
17+
18+
fn main() -> Unit {
19+
let ok = is_kw_leading(word: read "fn")
20+
let total = sum_trailing(a: 1, b: 2, c: 3)
21+
Log.write(message: read String.from_bool(value: ok))
22+
Log.write(message: read String.from_int(value: total))
23+
return Unit
24+
}

docs/ledgers/rss-selfhost-ledger.md

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -415,19 +415,23 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
415415
- **Classification:** language / parser (missing operator-continuation) + a
416416
correctness-grade diagnostics gap (leading-operator form should error, not
417417
silently truncate).
418-
- **Decision:** rule for all self-hosted rss — **keep a statement-level
419-
expression on one line**; break long boolean tests into a single line or into
420-
helper predicates / early-return `if`s. Worked around by making `is_kw` a
421-
single-line chain. Language-side follow-up: either support operator
422-
continuation or make the leading-operator form a hard error.
423-
- **Tests:** `crate::selfhost_parity::lexer_parity_tiny_sample`;
424-
fixture `tests/fixtures/fail/leading-operator-continuation.rss`.
425-
- **Status:** fixed: a leading-`||` statement continuation now emits RS0015
426-
("unsupported statement") instead of silently parsing as a discarded empty-arg
427-
closure. Guard added in `syntax/parser/stmt.rs` `parse_stmt` (statement
428-
fall-through only; `if`/`while`/`match` conditions and value/argument closures
429-
are unaffected). Operator continuation itself is still not supported; keep
430-
statement-level expressions on one line.
418+
- **Decision:** FIXED PROPERLY (2026-07-01) — statement-level expressions now
419+
**continue across newlines** on an unambiguous binary operator, so the wrapped
420+
chain that used to be silently-wrong is now *valid and correct*. In
421+
`syntax/parser/scan.rs` `statement_end`, a line that begins with, or follows a
422+
line ending in, one of `| & + * / % ^` continues the current statement (leading
423+
and trailing styles both work); `<`, `>`, `-`, `=`, `!` are excluded (generics /
424+
comparison / unary-minus, plus a dangling `let x =` and a leading `!expr` must
425+
NOT silently swallow the next line — that would reintroduce the SH-017 footgun),
426+
so a wrap can never swallow the start of a new statement. `==`/`!=`/`<=`/`>=`/`=`
427+
stay single-line. The interim safety guard in `stmt.rs` stays as a backstop for a
428+
genuine leading-`||` at a block start. Spec §A.1 updated with the normative
429+
statement-termination + continuation rule (reconciling the stale
430+
"not layout-sensitive" claim).
431+
- **Tests:** `tests/fixtures/pass/multiline-operator-continuation.rss` (leading
432+
and trailing styles); the former fail fixture was removed (the construct is now
433+
valid). Full suite + differential + self-host parity green.
434+
- **Status:** fixed (operator continuation supported).
431435

432436
### SH-018 — no cursor/state object: scan helpers must thread `(chars, n, index)` and return the new index
433437

@@ -521,17 +525,18 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
521525
}
522526
```
523527
- **Backend:** all (language ergonomics).
524-
- **Root cause:** no ergonomic multi-value/tuple return and no mutable cursor, so
525-
the classic "parser returns Result<Node, Err> while advancing self.pos" shape
526-
collapses into an overloaded sentinel Int. Fine for a recognizer (which only
527-
needs accept/reject), but a node-building parser would want a real result
528-
struct per nonterminal.
529-
- **Classification:** language (ergonomics) + docs — same family as SH-018.
530-
- **Decision:** worked around with the `-1`-sentinel convention; reaches full
531-
recognition parity (545/545). Recorded so the plumbing cost of a stateful
532-
recursive-descent pass in rss is visible alongside SH-018.
533-
- **Tests:** `crate::selfhost_parity::parser_parity_corpus` (recognition, 545/545).
534-
- **Status:** decided (worked around).
528+
- **Root cause (CORRECTED 2026-07-01):** the "no lightweight tuple return" premise
529+
was WRONG — verified that `fn f() -> (Bool, Int) { return (true, 5) }` with
530+
`let (ok, n) = f()` compiles and runs on the VM. Multi-value return via tuples
531+
works today, so a node-building parser CAN return `(new_index, node)`; the
532+
sentinel-`Int` convention was an unforced choice, not a language limit. The only
533+
genuine residual is the cursor plumbing itself (SH-018), which the
534+
`mut`-scalar-param write-back fix removes (pass the cursor as `mut pos`).
535+
- **Classification:** docs (the original entry over-claimed a non-existent gap).
536+
- **Decision:** WITHDRAWN as a language gap. Tuple returns work; the remaining
537+
ergonomic cost folds into SH-018 (cursor mutation), fixed separately.
538+
- **Tests:** verified by probe (`fn f() -> (Bool, Int)` + tuple destructuring).
539+
- **Status:** closed (not a gap — tuple returns work; over-claim corrected).
535540

536541
### SH-021 — `parse_source_raw` defers body validation: recognition parity under-tests the grammar
537542

docs/spec/RSScript_v0.7_Spec.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5815,8 +5815,20 @@ adds), but it fixes the surface shape so a parser and the prose agree.
58155815
```text
58165816
Tokens identifier | number | string | keyword | symbol | EOF
58175817
Comment `// ...` to end of line. There is no block-comment form.
5818-
Whitespace spaces, tabs, and newlines separate tokens and are otherwise
5819-
insignificant (the language is not layout-sensitive).
5818+
Whitespace spaces, tabs, and newlines separate tokens and produce no tokens
5819+
of their own. Newlines are significant only for statement
5820+
termination (below); they are otherwise insignificant.
5821+
Statement end A statement ends at a newline that sits at the top level of the
5822+
statement, EXCEPT when the expression is still open — i.e. an
5823+
unclosed `(` `[` `{`, a postfix continuation (`.`/`?` beginning or
5824+
ending the break), or a binary-operator continuation: a line that
5825+
begins with, or follows a line ending in, one of `| & + * / % ^`
5826+
(so `||`, `&&`, `+`, `*`, … chains may wrap across lines). `<`,
5827+
`>`, `-`, `=`, and `!` are deliberately NOT continuation operators
5828+
(generics/comparison, unary minus, and a dangling `let x =` or
5829+
leading `!expr` must not silently swallow the next line), so
5830+
`==`, `!=`, `<=`, `>=`, and `=` expressions must stay on one line.
5831+
An explicit `;` also ends a statement, allowing several per line.
58205832
Identifier ident-start (letter or `_`) followed by letters, digits, or `_`.
58215833
Number one or more digits, optionally `.` then one or more digits.
58225834
A fractional part makes it a Float literal; otherwise an Int

0 commit comments

Comments
 (0)