Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8c119369c1 fix(cli): support space-separated value for --config flag
The --config (and -c) CLI argument with a space-separated value was
silently doing nothing and exiting with code 0. Only the equals form
(--config=value) worked correctly.

The issue was in the clap argument parser's handling of `one_optional`
parameters. When no `=` was present, it immediately returned null
instead of checking if the next argument could be the value.

This fix adds a `peek()` method to the argument iterators and updates
the parser to consume the next argument for `one_optional` parameters
if it doesn't start with `-` (i.e., isn't another flag).

Fixes #25930.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:51:36 +00:00
3 changed files with 200 additions and 5 deletions

View File

@@ -28,6 +28,13 @@ pub const SliceIterator = struct {
}
return null;
}
pub fn peek(iter: *const SliceIterator) ?[]const u8 {
if (iter.remain.len > 0) {
return iter.remain[0];
}
return null;
}
};
test "SliceIterator" {
@@ -76,6 +83,13 @@ pub const OsIterator = struct {
return null;
}
pub fn peek(iter: *const OsIterator) ?[:0]const u8 {
if (iter.remain.len > 0) {
return iter.remain[0];
}
return null;
}
};
/// An argument iterator that takes a string and parses it into arguments, simulating

View File

@@ -64,12 +64,27 @@ pub fn StreamingClap(comptime Id: type, comptime ArgIterator: type) type {
if (!param.names.matchesLong(name))
continue;
if (param.takes_value == .none or param.takes_value == .one_optional) {
if (param.takes_value == .none and maybe_value != null) {
if (param.takes_value == .none) {
if (maybe_value != null) {
return parser.err(arg, .{ .long = name }, error.DoesntTakeValue);
}
return ArgType{ .param = param, .value = null };
}
return ArgType{ .param = param, .value = maybe_value };
if (param.takes_value == .one_optional) {
// For one_optional: if we have =value, use it; otherwise try to
// consume the next argument if it doesn't look like a flag
if (maybe_value) |v| {
return ArgType{ .param = param, .value = v };
}
// Peek at next arg - if it doesn't start with '-', use it as value
if (parser.iter.peek()) |next_arg| {
if (next_arg.len > 0 and next_arg[0] != '-') {
_ = parser.iter.next();
return ArgType{ .param = param, .value = next_arg };
}
}
return ArgType{ .param = param, .value = null };
}
const value = blk: {
@@ -149,12 +164,29 @@ pub fn StreamingClap(comptime Id: type, comptime ArgIterator: type) type {
}
const next_is_eql = if (next_index < arg.len) arg[next_index] == '=' else false;
if (param.takes_value == .none or param.takes_value == .one_optional) {
if (next_is_eql and param.takes_value == .none)
if (param.takes_value == .none) {
if (next_is_eql)
return parser.err(arg, .{ .short = short }, error.DoesntTakeValue);
return Arg(Id){ .param = param };
}
if (param.takes_value == .one_optional) {
// For -c=value, use value after =
if (next_is_eql)
return Arg(Id){ .param = param, .value = arg[next_index + 1 ..] };
// For -cvalue (attached), use the rest
if (arg.len > next_index)
return Arg(Id){ .param = param, .value = arg[next_index..] };
// For -c value (space-separated), peek at next arg
if (parser.iter.peek()) |next_arg| {
if (next_arg.len > 0 and next_arg[0] != '-') {
_ = parser.iter.next();
return Arg(Id){ .param = param, .value = next_arg };
}
}
return Arg(Id){ .param = param, .value = null };
}
if (arg.len <= next_index) {
const value = parser.iter.next() orelse
return parser.err(arg, .{ .short = short }, error.MissingValue);

View File

@@ -0,0 +1,149 @@
// https://github.com/oven-sh/bun/issues/25930
// bun --config <path> (space-separated) silently does nothing and exits 0
// only --config=<path> works
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
describe("--config flag should work with space-separated value", () => {
test("--config bunfig.toml -e (space-separated)", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--config", "bunfig.toml", "-e", "console.log('hello')"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
test("--config=bunfig.toml -e (equals form)", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--config=bunfig.toml", "-e", "console.log('hello')"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
test("-c bunfig.toml -e (short form, space-separated)", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "-c", "bunfig.toml", "-e", "console.log('hello')"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
test("-c=bunfig.toml -e (short form, equals)", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "-c=bunfig.toml", "-e", "console.log('hello')"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
test("bun run --config bunfig.toml (space-separated)", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
"package.json": JSON.stringify({ scripts: { test: "echo hello" } }),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "--config", "bunfig.toml", "test"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout).toContain("hello");
expect(exitCode).toBe(0);
});
test("--config without value should use default bunfig.toml", async () => {
using dir = tempDir("config-test", {
"bunfig.toml": "[install]\ncache = false",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--config", "-e", "console.log('hello')"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// When --config is followed by -e (starts with -), it should not consume -e
// and should use default bunfig.toml instead
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});
});