diff --git a/src/shell_parser/braces.rs b/src/shell_parser/braces.rs index 15928b0964cc..623755411f1c 100644 --- a/src/shell_parser/braces.rs +++ b/src/shell_parser/braces.rs @@ -957,23 +957,22 @@ impl<'a> Parser<'a> { fn parse_expansion(&mut self) -> Result { let mut variants: BumpVec<'a, ast::Group> = BumpVec::new_in(self.bump); - while !self.match_any(&[TokenTag::Close, TokenTag::Eof]) { + loop { let mut group: BumpVec<'a, ast::Atom> = BumpVec::new_in(self.bump); - let mut close = false; - while !self.r#match(TokenTag::Eof) { - if self.r#match(TokenTag::Close) { - close = true; - break; + // Only this inner loop consumes Close/Eof so a trailing empty + // variant (`{a,}`) is pushed before the outer loop exits. + let close = loop { + if self.match_any(&[TokenTag::Close, TokenTag::Eof]) { + break true; } if self.r#match(TokenTag::Comma) { - break; + break false; } - let group_atom = match self.parse_atom()? { - Some(a) => a, - None => break, - }; - group.push(group_atom); - } + match self.parse_atom()? { + Some(a) => group.push(a), + None => break true, + } + }; if group.len() == 1 { let single = group.into_iter().next().unwrap(); variants.push(ast::Group { diff --git a/test/js/bun/shell/brace.test.ts b/test/js/bun/shell/brace.test.ts index bb2d2b9b70cd..b3a330a97a73 100644 --- a/test/js/bun/shell/brace.test.ts +++ b/test/js/bun/shell/brace.test.ts @@ -44,6 +44,33 @@ describe("$.braces", () => { expect($.braces(`{{a,b}{c,d}{e,f}}`)).toEqual(["ace", "acf", "ade", "adf", "bce", "bcf", "bde", "bdf"]); }); + // The nested-expansion parser consumed `}` via the outer loop guard after a + // trailing `,`, so `{a,}` inside a nested group yielded one variant instead + // of two and the last output slot was left empty. + describe("nested with empty variant", () => { + test.each([ + ["{x,a{,}b}", ["x", "ab", "ab"]], + ["{x,{a,}}z", ["xz", "az", "z"]], + ["{x,{,a}}z", ["xz", "z", "az"]], + ["{x,{,}}z", ["xz", "z", "z"]], + ["a{b,c{d,}}e", ["abe", "acde", "ace"]], + ["a{b,c{,d}}e", ["abe", "ace", "acde"]], + ["{x,{a,,b}}", ["x", "a", "", "b"]], + ["{x,{a,b,}}", ["x", "a", "b", ""]], + ["{{a,},x}", ["a", "", "x"]], + ["{{a,}{b,}}", ["ab", "a", "b", ""]], + ["p{q,{r,}{s,}}t", ["pqt", "prst", "prt", "pst", "pt"]], + // A nested comma-free `{}` previously parsed to 0 variants, which made + // expand_nested return early and drop the text after it. It is now 1 + // empty variant, matching calculate_expanded_amount and expand_flat. + ["{x,a{}b}", ["x", "ab"]], + ["{a,b{}}c", ["ac", "bc"]], + ["{x,{}y}", ["x", "y"]], + ])("%s", (pattern, expected) => { + expect($.braces(pattern)).toEqual(expected); + }); + }); + test("very deeply nested", () => { const result = $.braces(`{1,{2,{3,{4,{5,{6,{7,{8,{9,{10,{11,{12,{13,{14,{15,{16,{17}}}}}}}}}}}}}}}}}`); expect(result).toEqual([