Skip to content

Commit 0a3af33

Browse files
simontreanorclaude
andcommitted
parser: reject let rec with a helpful hint
Pyfun has no `rec` keyword — a `let` function is already recursive in its own body (like a Python `def`). The F#/ML reflex `let rec f x = …` previously parsed as a function *named* `rec` with parameters `f x`, then failed downstream with a baffling `unbound name f`. Detect the shape in parse_let_binding and reject it with the caret on `rec` and a hint to drop it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a6c5dce commit 0a3af33

3 files changed

Lines changed: 30 additions & 2 deletions

File tree

DESIGN.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,8 +584,10 @@ no enforced visibility. All four shaping decisions were taken deliberately:
584584
- **All public.** Every top-level binding is exported; no `pub` keyword — Python has no enforced private
585585
(`_underscore` is convention only). Visibility control is **deferred**.
586586
- **Implicit recursion** (landed — slice 0, independent of the rest): a *function* binding (`let f x =
587-
`) is in scope in its own body, like Python's `def` — no `rec` keyword. A plain value binding still cannot
588-
self-refer (`let x = x` stays an error, as `x = x` is a module-level `NameError` in Python). Mechanism:
587+
`) is in scope in its own body, like Python's `def` — no `rec` keyword (an F#/ML `let rec f x = …` is
588+
rejected with a hint to drop `rec`, rather than silently binding a function named `rec`). A plain value
589+
binding still cannot self-refer (`let x = x` stays an error, as `x = x` is a module-level `NameError` in
590+
Python). Mechanism:
589591
pre-bind `f : α` (fresh) before inferring the body, unify, then generalize (standard monomorphic-
590592
recursion HM); lowering is unchanged (Python functions are already recursive). **Mutual recursion**
591593
(landed) extends this to *groups*: `run` builds the dependency graph among top-level `let` bindings

src/parser/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,19 @@ impl Parser {
673673
while let Tok::Ident(_) = self.peek() {
674674
params.push(self.parse_param()?);
675675
}
676+
// `let rec f x = …` is F#/ML muscle memory: Pyfun has no `rec` keyword — a
677+
// function binding is already recursive in its own body (like a Python `def`,
678+
// `DESIGN.md` §"Implicit recursion"). Left alone this would silently define a
679+
// function *named* `rec` with parameters `f x`, then fail downstream with a
680+
// baffling `unbound name f`. Catch the shape and say so.
681+
if name == "rec" && !params.is_empty() {
682+
return Err(ParseError {
683+
message: "`rec` is not a keyword in Pyfun — a `let` function is already \
684+
recursive in its own body, so drop `rec` (write `let f x = …`)"
685+
.to_string(),
686+
span: name_span.span(),
687+
});
688+
}
676689
if is_discard && !params.is_empty() {
677690
return Err(self.error("a discard binding `_` cannot take parameters"));
678691
}

tests/roundtrip.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,19 @@ fn reports_errors_for_malformed_input() {
620620
assert!(parse("(1 + 2").is_err()); // unbalanced paren
621621
}
622622

623+
#[test]
624+
fn let_rec_is_rejected_with_a_helpful_hint() {
625+
// Pyfun has no `rec` keyword (functions are implicitly recursive). The F#/ML
626+
// `let rec f x = …` would otherwise silently define a function named `rec`; we
627+
// catch it and point at the fix.
628+
let err = parse("let rec f x = x").unwrap_err();
629+
assert!(err.message().contains("`rec` is not a keyword"), "{}", err.message());
630+
// A binding genuinely named `rec` with no params is still fine (a plain value).
631+
assert!(parse("let rec = 1").is_ok());
632+
// …and so is the corrected form.
633+
assert!(parse("let f x = x").is_ok());
634+
}
635+
623636
#[test]
624637
fn doc_comment_attaches_to_the_following_declaration() {
625638
// `## …` lines at column 0 join (with `\n`) onto the next top-level

0 commit comments

Comments
 (0)