Skip to content

Commit 669fc00

Browse files
Fix star/dict-value SyntaxError false-positives vs CPython 3.14.5
The bare-star helpers only checked that the error-adjacent token was `*`, so a binary multiply with a missing operand was mislabeled "Invalid star expression". `{1 *}`, `(1 *)`, `f(a *)` (and `f(g(a *))`, `{(x) *}`, …) now correctly report "invalid syntax" like CPython. Require the `*` to start its slot — mirroring `is_invalid_star_in_subscript` — and share the check via `slot_starts_with_bare_star`. The dict-value arm emitted "expression expected after dictionary key and ':'" for any unparseable value; CPython reserves that message for a genuinely empty value and says "invalid syntax" otherwise. Restrict it to empty values via `dict_value_is_empty`, so `{1: *}`, `{1: **}`, `{1: 2*}`, `{1: +}`, `{1: not}`, … now match CPython. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent deb8e72 commit 669fc00

1 file changed

Lines changed: 72 additions & 19 deletions

File tree

crates/compiler/src/lib.rs

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,13 @@ impl CompileError {
328328
(ParseErrorType::OtherError(msg), loc, end_loc)
329329
}
330330

331-
// Dict literal: `{1:}` / `{1: 2, 3: 4, 5: }` — missing value.
331+
// Dict literal: `{1:}` / `{1: 2, 3: 4, 5: }` — *missing* value. CPython's
332+
// "expression expected after dictionary key and ':'" is specific to a
333+
// genuinely empty value; a present-but-unparseable value (`{1: *}`,
334+
// `{1: 2*}`, `{1: +}`) is plain "invalid syntax", so require emptiness.
332335
ParseErrorType::ExpectedExpression
333-
if is_dict_value_position(source_text, error.location) =>
336+
if is_dict_value_position(source_text, error.location)
337+
&& dict_value_is_empty(source_text, error.location) =>
334338
{
335339
let (loc, end_loc) = adjusted_locations(&source_code, error.location);
336340
let msg = "expression expected after dictionary key and ':'".to_owned();
@@ -1185,42 +1189,55 @@ fn is_bare_star_in_call(source: &str, range: ruff_text_size::TextRange) -> bool
11851189
{
11861190
return false;
11871191
}
1188-
// The token immediately before the error must be `*`.
1189-
prefix.trim_end().ends_with('*')
1192+
// The current argument slot (after the enclosing `(` or the last depth-0
1193+
// comma) must begin with a bare `*`; a `*` after an operand (`f(a *)`, a
1194+
// binary multiply) is plain "invalid syntax".
1195+
let args = &prefix[open_idx + 1..];
1196+
let mut d = 0i32;
1197+
let mut slot_start = 0;
1198+
for (j, c) in args.char_indices() {
1199+
match c {
1200+
'(' | '[' | '{' => d += 1,
1201+
')' | ']' | '}' => d -= 1,
1202+
',' if d == 0 => slot_start = j + 1,
1203+
_ => {}
1204+
}
1205+
}
1206+
slot_starts_with_bare_star(&args[slot_start..])
11901207
}
11911208

11921209
/// Detect a bare single `*` as the leading element of a set/dict display
11931210
/// `{ ... }` or a non-call parenthesised group/tuple `( ... )` — CPython's
1194-
/// "Invalid star expression" (`{*}`, `{*, 1}`, `(*)`, `(*,)`, `(*, 1)`). A
1195-
/// non-leading star (`{1, *}`, `(1, *)`), a dict value (`{1: *}`), a double
1196-
/// star (`{**}`), a subscript/list (`[*]`, handled elsewhere), or a call
1197-
/// (`f(*)`, handled elsewhere) is intentionally excluded.
1211+
/// "Invalid star expression" (`{*}`, `{*, 1}`, `(*)`, `(*,)`, `(*, 1)`). The
1212+
/// star must START the (leading) slot: a star following an operand (`{1 *}`, a
1213+
/// binary multiply), a comma (`{1, *}`), or a dict colon (`{1: *}`), as well as
1214+
/// a double star (`{**}`), a subscript/list (`[*]`), or a call (`f(*)`) are
1215+
/// excluded (the last two are handled by `is_invalid_star_in_subscript` /
1216+
/// `is_bare_star_in_call`).
11981217
fn is_bare_star_first_in_group(source: &str, range: ruff_text_size::TextRange) -> bool {
11991218
let start: usize = range.start().into();
12001219
let prefix = &source[..start];
1201-
let trimmed = prefix.trim_end();
1202-
// The token immediately before the error must be a single `*` (not `**`).
1203-
if !trimmed.ends_with('*') || trimmed.ends_with("**") {
1204-
return false;
1205-
}
1206-
// Walk back to the nearest depth-0 opener. Bail at a depth-0 `,` (the star
1207-
// is not the leading element), `:` (a dict value), or `[` (subscript/list,
1208-
// handled by `is_invalid_star_in_subscript`).
1220+
// Walk back to the nearest depth-0 opener; the leading slot (opener..error)
1221+
// must begin with a bare `*`. Bail at a depth-0 `,`/`:` (non-leading slot /
1222+
// dict value) or `[` (subscript/list, handled by `is_invalid_star_in_subscript`).
12091223
let mut depth = 0i32;
12101224
for (i, c) in prefix.char_indices().rev() {
12111225
match c {
12121226
')' | ']' | '}' => depth += 1,
12131227
',' | ':' if depth == 0 => return false,
12141228
'[' if depth == 0 => return false,
1215-
'{' if depth == 0 => return true,
1229+
'{' if depth == 0 => return slot_starts_with_bare_star(&prefix[i + 1..]),
12161230
'(' if depth == 0 => {
12171231
// Only a non-call group: the token before `(` must not be a
12181232
// callee (identifier / `)` / `]`).
12191233
let before = prefix[..i].chars().rev().find(|c| !c.is_whitespace());
1220-
return !matches!(
1234+
if matches!(
12211235
before,
12221236
Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == ')' || c == ']'
1223-
);
1237+
) {
1238+
return false;
1239+
}
1240+
return slot_starts_with_bare_star(&prefix[i + 1..]);
12241241
}
12251242
'(' | '{' | '[' => depth -= 1,
12261243
_ => {}
@@ -1229,6 +1246,13 @@ fn is_bare_star_first_in_group(source: &str, range: ruff_text_size::TextRange) -
12291246
false
12301247
}
12311248

1249+
/// Whether `slot` (the text from an opener to the error) begins with a single
1250+
/// bare `*` (not `**`) — i.e. a leading starred element with no operand.
1251+
fn slot_starts_with_bare_star(slot: &str) -> bool {
1252+
let s = slot.trim_start();
1253+
s.starts_with('*') && !s.starts_with("**")
1254+
}
1255+
12321256
/// Detect bad target in `except[*] T as <bad>:`. Returns the matching CPython
12331257
/// message (e.g. "cannot use except statement with attribute") or `None`.
12341258
fn except_as_bad_target_message(source: &str, range: ruff_text_size::TextRange) -> Option<String> {
@@ -1396,6 +1420,35 @@ fn is_dict_value_position(source: &str, range: ruff_text_size::TextRange) -> boo
13961420
matches!(last_relevant, Some(':'))
13971421
}
13981422

1423+
/// Whether the dict value position (after the nearest depth-0 `:` inside the
1424+
/// enclosing `{`) is empty — the next non-whitespace character is the `,`/`}`
1425+
/// delimiter (or end of input): `{1:}`, `{1: }`, `{1: ,}`. Only then does
1426+
/// CPython emit "expression expected after dictionary key and ':'"; a value
1427+
/// that is present but fails to parse (`{1: *}`, `{1: 2*}`, `{1: +}`) is plain
1428+
/// "invalid syntax". Used together with [`is_dict_value_position`], which
1429+
/// guarantees the nearest depth-0 separator is the `:`.
1430+
fn dict_value_is_empty(source: &str, range: ruff_text_size::TextRange) -> bool {
1431+
let start: usize = range.start().into();
1432+
let prefix = &source[..start];
1433+
let mut depth = 0i32;
1434+
for (i, c) in prefix.char_indices().rev() {
1435+
match c {
1436+
')' | ']' | '}' => depth += 1,
1437+
',' if depth == 0 => return false,
1438+
':' if depth == 0 => {
1439+
return matches!(
1440+
source[i + 1..].trim_start().chars().next(),
1441+
None | Some(',' | '}')
1442+
);
1443+
}
1444+
'(' | '[' | '{' if depth == 0 => return false,
1445+
'(' | '[' | '{' => depth -= 1,
1446+
_ => {}
1447+
}
1448+
}
1449+
false
1450+
}
1451+
13991452
/// Detect `X=Y` in tuple/list/set literals (i.e. not a function call).
14001453
/// CPython suggests "invalid syntax. Maybe you meant '==' or ':=' instead of
14011454
/// '='?".

0 commit comments

Comments
 (0)