Skip to content

Commit 8b3b02b

Browse files
olwangclaude
andcommitted
fix(frontend): resolve selfhost-ledger items SH-016/017/019/024
Four parity-safe frontend fixes surfaced by the self-hosting stress test (findings in docs/ledgers/rss-selfhost-ledger.md). No backend/lowering/runtime changes — the 6-backend parity holds by construction. - SH-017 (correctness bug): a statement beginning with `||` was misparsed as a discarded empty-arg closure, so wrapped boolean chains silently dropped their continuation. Now a leading-`||` statement emits RS0015 instead of a silent wrong result. (parser/stmt.rs) - SH-016: `'` had no lexer handling and mapped to `?`, cascading a misleading RS0013. Now `'…'` lexes as one token and yields a clear "no character-literal syntax" RS0015; the RS0013 cascade is gone. (lexer.rs + Program.char_literal_spans + analyzer; mirrored in selfhost/lexer.rss for parity) - SH-024: positional multi-field variant patterns (`V(a, b)`) emitted a misleading per-binding RS0026. Now a single targeted RS0037 points to the named form `V { a, b }` (positional records are excluded by spec §20.1). (parser + new diagnostic code) - SH-019 (root cause corrected): a `fresh`-returning builder failed RS0601 only after a loop/branch — the flow merge dropped MANAGED fresh bindings, keeping only exclusive `local` ones (straight-line always compiled). Fixed by keeping managed bindings across the merge. (checks/local.rs merge_flow_states + 3 siblings) Fixtures: fail/{char-literal-unsupported,leading-operator-continuation, positional-multifield-variant}.rss, pass/fresh-loop-built-list.rss. Gated: full suite green (CARGO_EXIT=0), self-host parity 550/550 (lexer/parser/ checker), clippy clean on touched files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cff4251 commit 8b3b02b

18 files changed

Lines changed: 372 additions & 33 deletions

File tree

crates/rsscript/src/analyzer.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ fn analyze_program(
208208
type_alias_params,
209209
in_task_group: false,
210210
async_let_names: Vec::new(),
211+
char_literal_spans: HashSet::new(),
211212
};
212213
analyzer.run();
213214
let mut diagnostics = analyzer.diagnostics;
@@ -274,6 +275,7 @@ fn analyze_syntax_program(
274275
type_alias_params: Default::default(),
275276
in_task_group: false,
276277
async_let_names: Vec::new(),
278+
char_literal_spans: HashSet::new(),
277279
};
278280
analyzer.run_syntax_only();
279281
let mut diagnostics = analyzer.diagnostics;
@@ -309,6 +311,11 @@ pub(crate) struct Analyzer<'a> {
309311
pub(crate) type_alias_params: std::collections::BTreeMap<String, Vec<String>>,
310312
in_task_group: bool,
311313
pub(crate) async_let_names: Vec<String>,
314+
/// Spans of folded `'...'` character-literal tokens. These get one targeted
315+
/// "no character-literal syntax" diagnostic; the generic `Expr::Unknown`
316+
/// walk skips them so the lone `'` operand does not also emit a duplicate
317+
/// RS0015.
318+
pub(crate) char_literal_spans: HashSet<crate::diagnostic::Span>,
312319
}
313320

314321
fn collect_task_group_async_lets(

crates/rsscript/src/analyzer/syntax_support.rs

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ use super::*;
22

33
impl Analyzer<'_> {
44
pub(super) fn check_unsupported_syntax(&mut self) {
5+
// A `'...'` character-literal attempt lexes to one `Symbol("'")` token.
6+
// Report it once with a clear message and remember its span so the
7+
// generic `Expr::Unknown` walk below does not emit a duplicate RS0015 for
8+
// the same lone `'` operand.
9+
let char_literal_spans = self.syntax_program.char_literal_spans.clone();
10+
self.char_literal_spans = char_literal_spans.iter().cloned().collect();
11+
for span in char_literal_spans {
12+
self.unsupported_syntax(
13+
span,
14+
"character literal",
15+
"RSScript has no character-literal syntax. Use a String literal (\"x\") or compare code points with Char.to_code(...).",
16+
);
17+
}
518
for span in self.syntax_program.unknown_top_level_spans.clone() {
619
self.unsupported_syntax(
720
span,
@@ -397,6 +410,7 @@ impl Analyzer<'_> {
397410
);
398411
}
399412
for arm in &stmt.arms {
413+
self.check_positional_multifield_pattern(&arm.pattern);
400414
self.check_unsupported_syntax_block(&arm.body);
401415
}
402416
}
@@ -465,6 +479,7 @@ impl Analyzer<'_> {
465479
Expr::Match { value, arms, .. } => {
466480
self.check_unsupported_syntax_expr(value);
467481
for arm in arms {
482+
self.check_positional_multifield_pattern(&arm.pattern);
468483
self.check_unsupported_syntax_block(&arm.body);
469484
}
470485
}
@@ -488,11 +503,18 @@ impl Analyzer<'_> {
488503
| Expr::Number(_, _)
489504
| Expr::String(_, _)
490505
| Expr::MultilineString(_, _) => {}
491-
Expr::Unknown(span) => self.unsupported_syntax(
492-
span.clone(),
493-
"unsupported expression",
494-
"This expression is outside the current RSScript parser surface.",
495-
),
506+
Expr::Unknown(span) => {
507+
// A lone `'` operand from a character-literal attempt already got
508+
// the targeted "character literal" diagnostic; do not also emit
509+
// the generic unsupported-expression RS0015 for it.
510+
if !self.char_literal_spans.contains(span) {
511+
self.unsupported_syntax(
512+
span.clone(),
513+
"unsupported expression",
514+
"This expression is outside the current RSScript parser surface.",
515+
);
516+
}
517+
}
496518
}
497519
}
498520

@@ -586,6 +608,67 @@ impl Analyzer<'_> {
586608
}
587609
}
588610

611+
/// Walk a match pattern and emit RS0037 for any positional multi-field
612+
/// variant attempt (`Both(a, b)`), descending through nested patterns so a
613+
/// deeper `Some(Both(a, b))` is caught too.
614+
pub(super) fn check_positional_multifield_pattern(&mut self, pattern: &MatchPattern) {
615+
match pattern {
616+
MatchPattern::Variant {
617+
name,
618+
positional_multifield,
619+
span,
620+
..
621+
} if !positional_multifield.is_empty() => {
622+
self.positional_multifield_variant(span.clone(), name, positional_multifield);
623+
}
624+
MatchPattern::Variant {
625+
binding: Some(binding),
626+
..
627+
} => self.check_positional_multifield_pattern(binding),
628+
MatchPattern::Struct { fields, .. } => {
629+
for field in fields {
630+
if let Some(pattern) = &field.pattern {
631+
self.check_positional_multifield_pattern(pattern);
632+
}
633+
}
634+
}
635+
MatchPattern::List { prefix, suffix, .. } => {
636+
for pattern in prefix.iter().chain(suffix) {
637+
self.check_positional_multifield_pattern(pattern);
638+
}
639+
}
640+
MatchPattern::Variant { .. }
641+
| MatchPattern::Binding { .. }
642+
| MatchPattern::Literal { .. }
643+
| MatchPattern::Wildcard(_) => {}
644+
}
645+
}
646+
647+
fn positional_multifield_variant(
648+
&mut self,
649+
span: crate::diagnostic::Span,
650+
name: &str,
651+
fields: &[String],
652+
) {
653+
let named = fields.join(", ");
654+
self.diagnostics.push(
655+
Diagnostic::error(
656+
code::POSITIONAL_MULTIFIELD_VARIANT,
657+
format!("positional multi-field variant pattern `{name}(...)`."),
658+
span,
659+
"positional multi-field variant",
660+
)
661+
.with_cause(format!(
662+
"multi-field variant `{name}` must be matched with named fields: `{name} {{ {named} }}`."
663+
))
664+
.with_fix(
665+
"use_named_variant_fields",
666+
format!("Rewrite the pattern as `{name} {{ {named} }}`."),
667+
"manual",
668+
),
669+
);
670+
}
671+
589672
pub(super) fn unsupported_syntax(
590673
&mut self,
591674
span: crate::diagnostic::Span,

crates/rsscript/src/checks/body/semantics.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ pub(super) fn check_match_pattern_matches_type(
505505
name,
506506
binding,
507507
span,
508+
..
508509
} if root == "Option" => {
509510
if !matches!(name.as_str(), "Some" | "None") {
510511
push_match_variant_type_mismatch(
@@ -528,6 +529,7 @@ pub(super) fn check_match_pattern_matches_type(
528529
name,
529530
binding,
530531
span,
532+
..
531533
} if root == "Result" => {
532534
if !matches!(name.as_str(), "Ok" | "Err") {
533535
push_match_variant_type_mismatch(
@@ -556,6 +558,7 @@ pub(super) fn check_match_pattern_matches_type(
556558
name,
557559
binding,
558560
span,
561+
..
559562
} => {
560563
let Some((_, fields)) = pattern_sum_variant_fields(analyzer, root, name) else {
561564
let allowed = allowed_sum_variant_names(analyzer, root);

crates/rsscript/src/checks/local.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3121,10 +3121,17 @@ fn merge_flow_states(left: &BodyState, right: &BodyState) -> BodyState {
31213121
}
31223122
moved_paths.retain(|path, _| path_root(path).is_some_and(|root| locals.contains(root)));
31233123

3124+
// A fresh binding survives the merge when it is clean in both predecessors
3125+
// and still tracked as either an exclusive `local` or a managed `let`/`let
3126+
// mut` binding. Keeping managed bindings (not just exclusive locals) lets a
3127+
// fresh builder pattern that runs inside a loop/branch still return cleanly.
3128+
// This stays sound: any aliasing invalidation (manage/retain/take/capture)
3129+
// already removes the binding from the predecessor `clean_locals`
3130+
// intersection, so an aliased binding can never reach this filter.
31243131
let clean_locals = left
31253132
.clean_locals
31263133
.intersection(&right.clean_locals)
3127-
.filter(|name| locals.contains(*name))
3134+
.filter(|name| locals.contains(*name) || managed.contains(*name))
31283135
.cloned()
31293136
.collect::<HashSet<_>>();
31303137
let fresh_returnable_locals = left
@@ -3666,7 +3673,7 @@ pub(crate) fn merge_loop_state(
36663673
state.clean_locals = base
36673674
.clean_locals
36683675
.intersection(&body_state.clean_locals)
3669-
.filter(|name| base.locals.contains(*name))
3676+
.filter(|name| base.locals.contains(*name) || base.managed.contains(*name))
36703677
.cloned()
36713678
.collect();
36723679
state.fresh_returnable_locals = base
@@ -3695,7 +3702,7 @@ fn fallthrough_projection(base: &BodyState, branch: &BodyState) -> BodyState {
36953702
let clean_locals = branch
36963703
.clean_locals
36973704
.intersection(&base.clean_locals)
3698-
.filter(|name| base.locals.contains(*name))
3705+
.filter(|name| base.locals.contains(*name) || base.managed.contains(*name))
36993706
.cloned()
37003707
.collect::<HashSet<_>>();
37013708
let fresh_returnable_locals = branch
@@ -3734,7 +3741,7 @@ fn merge_fallthrough_states(base: &BodyState, left: &BodyState, right: &BodyStat
37343741
let clean_locals = left
37353742
.clean_locals
37363743
.intersection(&right.clean_locals)
3737-
.filter(|name| base.locals.contains(*name))
3744+
.filter(|name| base.locals.contains(*name) || base.managed.contains(*name))
37383745
.cloned()
37393746
.collect::<HashSet<_>>();
37403747
let fresh_returnable_locals = left

crates/rsscript/src/diagnostic.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod code {
4040
pub const UNINFERABLE_BINDING_TYPE: &str = "RS0034";
4141
pub const LOWER_NAME_CONFLICT: &str = "RS0035";
4242
pub const MESSAGE_PAYLOAD_NOT_TRANSFERABLE: &str = "RS0036";
43+
pub const POSITIONAL_MULTIFIELD_VARIANT: &str = "RS0037";
4344
pub const FEATURE_VIOLATION: &str = "RS0101";
4445
pub const UNNAMED_ARGUMENT: &str = "RS0201";
4546
pub const MISSING_DATA_EFFECT: &str = "RS0202";
@@ -550,6 +551,11 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
550551
title: "unknown binding",
551552
explanation: "Value identifiers must resolve to a visible parameter, local binding, with-bound resource, or pattern binding before Rust lowering.",
552553
},
554+
DiagnosticExplanation {
555+
code: code::POSITIONAL_MULTIFIELD_VARIANT,
556+
title: "positional multi-field variant pattern",
557+
explanation: "A sum-type variant with two or more fields must be matched with named fields (`V { a, b }`), not a positional payload (`V(a, b)`). RSScript excludes positional records (spec §20.1): only single-payload positional patterns like `Some(v)` or `Circle(r)` and named field patterns like `V { a, b }` are allowed. The parser keeps the attempted binder names so they still resolve inside the arm — avoiding a misleading per-binding `unknown value binding` (RS0026) — and reports this single targeted diagnostic instead.",
558+
},
553559
DiagnosticExplanation {
554560
code: code::UNKNOWN_PROTOCOL,
555561
title: "unknown protocol",

crates/rsscript/src/lexer.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ impl Lexer<'_> {
7676
'"' => self.lex_string(),
7777
ch if ch.is_ascii_digit() => self.lex_number(),
7878
ch if is_ident_start(ch) => self.lex_ident_or_keyword(),
79+
'\'' => self.lex_char_literal(),
7980
'-' if self.peek_next() == Some('>') => self.push_two("->"),
8081
'=' if self.peek_next() == Some('>') => self.push_two("=>"),
8182
':' | ',' | '.' | '(' | ')' | '{' | '}' | '<' | '>' | '[' | ']' | '?' | '|'
@@ -280,6 +281,39 @@ impl Lexer<'_> {
280281
});
281282
}
282283

284+
/// RSScript has no character-literal syntax. Without a dedicated lexer arm a
285+
/// leading `'` would be mapped to `Symbol("?")` by `push_one`, so `'x'` lexed
286+
/// to `? x ?` and the trailing `?` produced a misleading RS0013 (try
287+
/// operator) cascade. Instead, consume the whole `'...'` run as a single
288+
/// `Symbol("'")` token so the analyzer can surface one clear "no
289+
/// character-literal syntax" diagnostic. The scan stops at the closing `'`,
290+
/// a newline, or EOF (so an unterminated `'` cannot swallow the rest of the
291+
/// line).
292+
fn lex_char_literal(&mut self) {
293+
let start_line = self.line;
294+
let start_column = self.column;
295+
let token_start = self.index;
296+
self.bump();
297+
while let Some(ch) = self.peek() {
298+
if ch == '\'' || ch == '\n' {
299+
break;
300+
}
301+
self.bump();
302+
}
303+
if self.peek() == Some('\'') {
304+
self.bump();
305+
}
306+
self.tokens.push(Token {
307+
kind: TokenKind::Symbol("'"),
308+
span: Span {
309+
file: self.file.to_string(),
310+
line: start_line,
311+
column: start_column,
312+
length: self.index.saturating_sub(token_start).max(1),
313+
},
314+
});
315+
}
316+
283317
fn bump_whitespace(&mut self) {
284318
while self.peek().is_some_and(char::is_whitespace) {
285319
self.bump();

crates/rsscript/src/package/native.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ pub(super) fn native_binding_interface_sources(
318318
profile_spans: Vec::new(),
319319
unknown_top_level_spans: Vec::new(),
320320
malformed_declaration_spans: Vec::new(),
321+
char_literal_spans: Vec::new(),
321322
protocols: Vec::new(),
322323
protocol_impls: Vec::new(),
323324
items: selected_items,

crates/rsscript/src/syntax/ast.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ pub struct Program {
3939
pub profile_spans: Vec<Span>,
4040
pub unknown_top_level_spans: Vec<Span>,
4141
pub malformed_declaration_spans: Vec<Span>,
42+
/// Spans of `'...'` runs the lexer folded into a single `Symbol("'")` token.
43+
/// RSScript has no character-literal syntax; the analyzer drains these into a
44+
/// clear diagnostic instead of a misleading try-operator cascade.
45+
pub char_literal_spans: Vec<Span>,
4246
pub protocols: Vec<ProtocolDecl>,
4347
pub protocol_impls: Vec<ProtocolImpl>,
4448
pub items: Vec<Item>,
@@ -69,6 +73,7 @@ pub fn merge_programs(programs: impl IntoIterator<Item = Program>) -> Program {
6973
let mut profile_spans = Vec::new();
7074
let mut unknown_top_level_spans = Vec::new();
7175
let mut malformed_declaration_spans = Vec::new();
76+
let mut char_literal_spans = Vec::new();
7277
let mut protocols = Vec::new();
7378
let mut protocol_impls = Vec::new();
7479
let mut items = Vec::new();
@@ -97,6 +102,7 @@ pub fn merge_programs(programs: impl IntoIterator<Item = Program>) -> Program {
97102
duplicate_features.extend(program.duplicate_features);
98103
unknown_top_level_spans.extend(program.unknown_top_level_spans);
99104
malformed_declaration_spans.extend(program.malformed_declaration_spans);
105+
char_literal_spans.extend(program.char_literal_spans);
100106
if program.feature_spans.len() > 1 {
101107
feature_spans.extend(program.feature_spans);
102108
}
@@ -115,6 +121,7 @@ pub fn merge_programs(programs: impl IntoIterator<Item = Program>) -> Program {
115121
profile_spans,
116122
unknown_top_level_spans,
117123
malformed_declaration_spans,
124+
char_literal_spans,
118125
protocols,
119126
protocol_impls,
120127
items,
@@ -522,6 +529,14 @@ pub enum MatchPattern {
522529
Variant {
523530
name: String,
524531
binding: Option<Box<MatchPattern>>,
532+
/// Non-empty only for a rejected positional multi-field attempt like
533+
/// `Both(a, b)`. Positional records are excluded by spec §20.1 (only
534+
/// single-payload positional `Some(v)` and named `V { a, b }` are
535+
/// allowed). The parser keeps the attempted binder names here so the
536+
/// analyzer can emit a targeted "use named fields" diagnostic and so the
537+
/// names still register as arm bindings (suppressing a misleading
538+
/// per-binding RS0026). `binding` stays `None` in this case.
539+
positional_multifield: Vec<String>,
525540
span: Span,
526541
},
527542
Struct {
@@ -564,6 +579,17 @@ impl MatchPattern {
564579
binding: Some(binding),
565580
..
566581
} => binding.binding_names(),
582+
// A rejected positional multi-field variant (`Both(a, b)`) still
583+
// exposes its attempted binders so the arm body's uses resolve and do
584+
// not trigger a misleading per-binding RS0026 on top of the targeted
585+
// diagnostic the analyzer emits.
586+
Self::Variant {
587+
binding: None,
588+
positional_multifield,
589+
..
590+
} if !positional_multifield.is_empty() => {
591+
positional_multifield.iter().map(String::as_str).collect()
592+
}
567593
Self::Struct { fields, .. } => fields
568594
.iter()
569595
.flat_map(|field| {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ impl Parser<'_> {
184184
}
185185
}
186186

187+
// A `'` token is always an error: the lexer only emits `Symbol("'")` for a
188+
// `'...'` character-literal attempt, which RSScript does not support. A
189+
// flat scan of the token stream collects every such span for the analyzer
190+
// to report with one clear message.
191+
let char_literal_spans = self
192+
.tokens
193+
.iter()
194+
.filter(|token| token.symbol("'"))
195+
.map(|token| token.span.clone())
196+
.collect();
197+
187198
Program {
188199
feature_scopes: vec![FileFeatureScope {
189200
file: self.file.to_string(),
@@ -196,6 +207,7 @@ impl Parser<'_> {
196207
profile_spans,
197208
unknown_top_level_spans,
198209
malformed_declaration_spans,
210+
char_literal_spans,
199211
protocols,
200212
protocol_impls,
201213
items,

0 commit comments

Comments
 (0)