diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index 53920af14476fc..ab04bf72c80518 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -1751,6 +1751,10 @@ pub const Interpreter = struct { } }; + pub fn format(this: *const Expansion, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + try writer.print("Expansion(0x{x})", .{@intFromPtr(this)}); + } + pub fn init( interpreter: *ThisInterpreter, shell_state: *ShellState, @@ -2115,6 +2119,7 @@ pub const Interpreter = struct { } fn onGlobWalkDone(this: *Expansion, task: *ShellGlobTask) void { + log("{} onGlobWalkDone", .{this}); if (comptime bun.Environment.allow_assert) { std.debug.assert(this.child_state == .glob); } @@ -2133,6 +2138,16 @@ pub const Interpreter = struct { } if (task.result.items.len == 0) { + // In variable assignments, a glob that fails to match should not produce an error, but instead expand to just the pattern + if (this.parent.ptr.is(Assigns) or (this.parent.ptr.is(Cmd) and this.parent.ptr.as(Cmd).state == .expanding_assigns)) { + this.pushCurrentOut(); + this.child_state.glob.walker.deinit(true); + this.child_state = .idle; + this.state = .done; + this.next(); + return; + } + const msg = std.fmt.allocPrint(bun.default_allocator, "no matches found: {s}", .{this.child_state.glob.walker.pattern}) catch bun.outOfMemory(); this.state = .{ .err = bun.shell.ShellErr{ @@ -2678,8 +2693,13 @@ pub const Interpreter = struct { } else { const size = brk: { var total: usize = 0; - for (expanding.current_expansion_result.items) |slice| { + const last = expanding.current_expansion_result.items.len -| 1; + for (expanding.current_expansion_result.items, 0..) |slice, i| { total += slice.len; + if (i != last) { + // for space + total += 1; + } } break :brk total; }; @@ -2687,9 +2707,14 @@ pub const Interpreter = struct { const value = brk: { var merged = bun.default_allocator.allocSentinel(u8, size, 0) catch bun.outOfMemory(); var i: usize = 0; - for (expanding.current_expansion_result.items) |slice| { + const last = expanding.current_expansion_result.items.len -| 1; + for (expanding.current_expansion_result.items, 0..) |slice, j| { @memcpy(merged[i .. i + slice.len], slice[0..slice.len]); i += slice.len; + if (j != last) { + merged[i] = ' '; + i += 1; + } } break :brk merged; }; diff --git a/src/shell/shell.zig b/src/shell/shell.zig index a21220936f8890..8665f26275efb8 100644 --- a/src/shell/shell.zig +++ b/src/shell/shell.zig @@ -816,6 +816,50 @@ pub const AST = struct { pub const Tag = enum(u8) { simple, compound }; + pub fn merge(this: Atom, right: Atom, allocator: Allocator) !Atom { + if (this == .simple and right == .simple) { + var atoms = try allocator.alloc(SimpleAtom, 2); + atoms[0] = this.simple; + atoms[1] = right.simple; + return .{ .compound = .{ + .atoms = atoms, + .brace_expansion_hint = this.simple == .brace_begin or this.simple == .brace_end or right.simple == .brace_begin or right.simple == .brace_end, + .glob_hint = this.simple == .asterisk or this.simple == .double_asterisk or right.simple == .asterisk or right.simple == .double_asterisk, + } }; + } + + if (this == .compound and right == .compound) { + var atoms = try allocator.alloc(SimpleAtom, this.compound.atoms.len + right.compound.atoms.len); + @memcpy(atoms[0..this.compound.atoms.len], this.compound.atoms); + @memcpy(atoms[this.compound.atoms.len .. this.compound.atoms.len + right.compound.atoms.len], right.compound.atoms); + return .{ .compound = .{ + .atoms = atoms, + .brace_expansion_hint = this.compound.brace_expansion_hint or right.compound.brace_expansion_hint, + .glob_hint = this.compound.glob_hint or right.compound.glob_hint, + } }; + } + + if (this == .simple) { + var atoms = try allocator.alloc(SimpleAtom, 1 + right.compound.atoms.len); + atoms[0] = this.simple; + @memcpy(atoms[1 .. right.compound.atoms.len + 1], right.compound.atoms); + return .{ .compound = .{ + .atoms = atoms, + .brace_expansion_hint = this.simple == .brace_begin or this.simple == .brace_end or right.compound.brace_expansion_hint, + .glob_hint = this.simple == .asterisk or this.simple == .double_asterisk or right.compound.glob_hint, + } }; + } + + var atoms = try allocator.alloc(SimpleAtom, 1 + this.compound.atoms.len); + @memcpy(atoms[0..this.compound.atoms.len], this.compound.atoms); + atoms[this.compound.atoms.len] = right.simple; + return .{ .compound = .{ + .atoms = atoms, + .brace_expansion_hint = right.simple == .brace_begin or right.simple == .brace_end or this.compound.brace_expansion_hint, + .glob_hint = right.simple == .asterisk or right.simple == .double_asterisk or this.compound.glob_hint, + } }; + } + pub fn atomsLen(this: *const Atom) u32 { return switch (this.*) { .simple => 1, @@ -1104,7 +1148,7 @@ pub const Parser = struct { fn expectIfClauseTextToken(self: *Parser, comptime if_clause_token: @TypeOf(.EnumLiteral)) Token { const tagname = comptime extractIfClauseTextToken(if_clause_token); - std.debug.assert(@as(TokenTag, self.peek()) == .Text); + if (bun.Environment.allow_assert) std.debug.assert(@as(TokenTag, self.peek()) == .Text); if (self.peek() == .Text and self.delimits(self.peek_n(1)) and std.mem.eql(u8, self.text(self.peek().Text), tagname)) @@ -1113,7 +1157,7 @@ pub const Parser = struct { _ = self.expect_delimit(); return tok; } - unreachable; + @panic("Expected: " ++ @tagName(if_clause_token)); } fn isIfClauseTextToken(self: *Parser, comptime if_clause_token: @TypeOf(.EnumLiteral)) bool { @@ -1500,7 +1544,7 @@ pub const Parser = struct { } if (eq_idx == txt.len - 1) { - if (self.peek() == .Delimit) { + if (self.delimits(self.peek())) { _ = self.expect_delimit(); break :var_decl .{ .label = label, @@ -1518,10 +1562,25 @@ pub const Parser = struct { } const txt_value = txt[eq_idx + 1 .. txt.len]; - _ = self.expect_delimit(); + if (self.delimits(self.peek())) { + _ = self.expect_delimit(); + break :var_decl .{ + .label = label, + .value = .{ .simple = .{ .Text = txt_value } }, + }; + } + + const right = try self.parse_atom() orelse { + try self.add_error("Expected an atom", .{}); + return ParseError.Expected; + }; + const left: AST.Atom = .{ + .simple = .{ .Text = txt_value }, + }; + const merged = try AST.Atom.merge(left, right, self.alloc); break :var_decl .{ .label = label, - .value = .{ .simple = .{ .Text = txt_value } }, + .value = merged, }; } break :var_decl null; @@ -1680,7 +1739,7 @@ pub const Parser = struct { return switch (atoms.items.len) { 0 => null, 1 => { - std.debug.assert(atoms.capacity == 1); + if (bun.Environment.allow_assert) std.debug.assert(atoms.capacity == 1); return AST.Atom.new_simple(atoms.items[0]); }, else => .{ .compound = .{ @@ -1713,22 +1772,20 @@ pub const Parser = struct { } fn expect(self: *Parser, toktag: TokenTag) Token { - std.debug.assert(toktag == @as(TokenTag, self.peek())); + if (bun.Environment.allow_assert) std.debug.assert(toktag == @as(TokenTag, self.peek())); if (self.check(toktag)) { return self.advance(); } - unreachable; + @panic("Unexpected token"); } fn expect_any(self: *Parser, toktags: []const TokenTag) Token { - // std.debug.assert(toktag == @as(TokenTag, self.peek())); - const peeked = self.peek(); for (toktags) |toktag| { if (toktag == @as(TokenTag, peeked)) return self.advance(); } - unreachable; + @panic("Unexpected token"); } fn delimits(self: *Parser, tok: Token) bool { @@ -1736,11 +1793,11 @@ pub const Parser = struct { } fn expect_delimit(self: *Parser) Token { - std.debug.assert(self.delimits(self.peek())); + if (bun.Environment.allow_assert) std.debug.assert(self.delimits(self.peek())); if (self.check(.Delimit) or self.check(.Semicolon) or self.check(.Newline) or self.check(.Eof) or (self.inside_subshell != null and self.check(self.inside_subshell.?.closing_tok()))) { return self.advance(); } - unreachable; + @panic("Expected a delimiter token"); } fn match_if_clausetok(self: *Parser, toktag: IfClauseTok) bool { @@ -2649,6 +2706,7 @@ pub fn NewLexer(comptime encoding: StringEncoding) type { .Comma, .BraceEnd, .CmdSubstEnd, + .Asterisk, => true, .Pipe, @@ -2657,7 +2715,6 @@ pub fn NewLexer(comptime encoding: StringEncoding) type { .DoubleAmpersand, .Redirect, .Dollar, - .Asterisk, .DoubleAsterisk, .Eq, .Semicolon, @@ -3383,7 +3440,7 @@ pub fn ShellCharIter(comptime encoding: StringEncoding) type { else => return .{ .char = char, .escaped = false }, } }, - else => unreachable, + .Single => unreachable, } return .{ .char = char, .escaped = true }; @@ -4397,7 +4454,7 @@ pub const TestingAPIs = struct { const script_ast = Interpreter.parse(&arena, script.items[0..], jsobjs.items[0..], jsstrings.items[0..], &out_parser, &out_lex_result) catch |err| { if (err == ParseError.Lex) { - std.debug.assert(out_lex_result != null); + if (bun.Environment.allow_assert) std.debug.assert(out_lex_result != null); const str = out_lex_result.?.combineErrors(arena.allocator()); globalThis.throwPretty("{s}", .{str}); return .undefined; diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 3459efb0268c0d..403cbfb408d2c8 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -352,22 +352,35 @@ describe("bunshell", () => { }); describe("glob expansion", () => { - test("No matches should fail", async () => { - // Issue #8403: https://github.com/oven-sh/bun/issues/8403 - await TestBuilder.command`ls *.sdfljsfsdf`.exitCode(1).stderr("bun: no matches found: *.sdfljsfsdf\n").run(); - }); + // Issue #8403: https://github.com/oven-sh/bun/issues/8403 + TestBuilder.command`ls *.sdfljsfsdf` + .exitCode(1) + .stderr("bun: no matches found: *.sdfljsfsdf\n") + .runAsTest("No matches should fail"); + + TestBuilder.command`FOO=*.lolwut; echo $FOO` + .stdout("*.lolwut\n") + .runAsTest("No matches in assignment position should print out pattern"); - test("Should work with a different cwd", async () => { + TestBuilder.command`FOO=hi*; echo $FOO` + .ensureTempDir() + .stdout("hi*\n") + .runAsTest("Trailing asterisk with no matches"); + + TestBuilder.command`touch hihello; touch hifriends; FOO=hi*; echo $FOO` + .ensureTempDir() + .stdout(s => expect(s).toBeOneOf(["hihello hifriends\n", "hifriends hihello\n"])) + .runAsTest("Trailing asterisk with matches, inline"); + + TestBuilder.command`ls *.js` // Calling `ensureTempDir()` changes the cwd here - await TestBuilder.command`ls *.js` - .ensureTempDir() - .file("foo.js", "foo") - .file("bar.js", "bar") - .stdout(out => { - expect(sortedShellOutput(out)).toEqual(sortedShellOutput("foo.js\nbar.js\n")); - }) - .run(); - }); + .ensureTempDir() + .file("foo.js", "foo") + .file("bar.js", "bar") + .stdout(out => { + expect(sortedShellOutput(out)).toEqual(sortedShellOutput("foo.js\nbar.js\n")); + }) + .runAsTest("Should work with a different cwd"); }); describe("brace expansion", () => {