diff --git a/builtins/builtins.go b/builtins/builtins.go index 6d78301b..608d7d62 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -38,6 +38,11 @@ type Command struct { Description string Help string MakeFlags func(*FlagSet) HandlerFunc + + // NormalizeArgs, if non-nil, rewrites raw argument slices before pflag + // parsing. This allows commands to support legacy flag syntax that pflag + // cannot handle natively (e.g. head/tail -5 → -n 5). + NormalizeArgs func(args []string) []string } // NoFlags wraps a HandlerFunc in the MakeFlags format for commands that @@ -57,6 +62,7 @@ func NoFlags(fn HandlerFunc) func(*FlagSet) HandlerFunc { func (c Command) Register() { name := c.Name factory := c.MakeFlags + normalize := c.NormalizeArgs metaRegistry[name] = CommandMeta{Name: name, Description: c.Description, Help: c.Help} addToRegistry(name, func(ctx context.Context, callCtx *CallContext, args []string) Result { fs := pflag.NewFlagSet(name, pflag.ContinueOnError) @@ -66,6 +72,9 @@ func (c Command) Register() { // No flags declared: pass all args through unchanged. return handler(ctx, callCtx, args) } + if normalize != nil { + args = normalize(args) + } if err := fs.Parse(args); err != nil { callCtx.Errf("%s: %v\n", name, err) return Result{Code: 1} @@ -244,3 +253,32 @@ func Meta(name string) (CommandMeta, bool) { m, ok := metaRegistry[name] return m, ok } + +// NormalizeBareNumberArg rewrites legacy -N shorthand (e.g. -5) to -n N so +// that pflag can parse it. Only a bare - token in the first argument +// position is rewritten; - appearing later in the argument list is +// left unchanged (matching GNU head/tail behavior where the obsolete form is +// only accepted as the first option). Processing stops at "--". +// +// When the first argument is a value-taking flag (-n, -c, --lines, --bytes), +// the second argument is its value and must not be rewritten — even if it +// looks like - (e.g. "head -n -9223372036854775809"). +// +// valueFlags lists the flags that consume the next argument as a value +// (e.g. []string{"-n", "-c", "--lines", "--bytes"}). +func NormalizeBareNumberArg(args []string, valueFlags []string) []string { + if len(args) == 0 { + return args + } + a := args[0] + if a == "--" { + return args + } + if len(a) >= 2 && a[0] == '-' && a[1] >= '0' && a[1] <= '9' { + out := make([]string, 0, len(args)+1) + out = append(out, "-n", a[1:]) + out = append(out, args[1:]...) + return out + } + return args +} diff --git a/builtins/head/head.go b/builtins/head/head.go index 6d0fc330..45e44168 100644 --- a/builtins/head/head.go +++ b/builtins/head/head.go @@ -61,9 +61,18 @@ import ( // Cmd is the head builtin command descriptor. var Cmd = builtins.Command{ - Name: "head", - Description: "output the first part of files", - MakeFlags: registerFlags, + Name: "head", + Description: "output the first part of files", + MakeFlags: registerFlags, + NormalizeArgs: normalizeArgs, +} + +// normalizeArgs rewrites legacy -N shorthand (e.g. -5) to -n N so that +// pflag can parse it. Only a bare - token in the first argument +// position is rewritten, matching GNU head behavior where the obsolete form +// is only accepted as the first option. +func normalizeArgs(args []string) []string { + return builtins.NormalizeBareNumberArg(args, []string{"-n", "-c", "--lines", "--bytes"}) } // MaxCount is the maximum accepted line or byte count. Values above this diff --git a/builtins/tail/tail.go b/builtins/tail/tail.go index e5f50270..845d92bc 100644 --- a/builtins/tail/tail.go +++ b/builtins/tail/tail.go @@ -86,9 +86,18 @@ import ( // Cmd is the tail builtin command descriptor. var Cmd = builtins.Command{ - Name: "tail", - Description: "output the last part of files", - MakeFlags: registerFlags, + Name: "tail", + Description: "output the last part of files", + MakeFlags: registerFlags, + NormalizeArgs: normalizeArgs, +} + +// normalizeArgs rewrites legacy -N shorthand (e.g. -5) to -n N so that +// pflag can parse it. Only a bare - token in the first argument +// position is rewritten, matching GNU tail behavior where the obsolete form +// is only accepted as the first option. +func normalizeArgs(args []string) []string { + return builtins.NormalizeBareNumberArg(args, []string{"-n", "-c", "--lines", "--bytes"}) } // MaxCount is the maximum accepted line or byte count. Values above this diff --git a/tests/scenarios/cmd/head/lines/bare_number_shorthand.yaml b/tests/scenarios/cmd/head/lines/bare_number_shorthand.yaml new file mode 100644 index 00000000..89fa32cc --- /dev/null +++ b/tests/scenarios/cmd/head/lines/bare_number_shorthand.yaml @@ -0,0 +1,13 @@ +description: head -3 (bare numeric shorthand) outputs the first 3 lines, equivalent to head -n 3. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + head -3 file.txt +expect: + stdout: "alpha\nbeta\ngamma\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/bare_number_shorthand_large.yaml b/tests/scenarios/cmd/head/lines/bare_number_shorthand_large.yaml new file mode 100644 index 00000000..c8d94f02 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/bare_number_shorthand_large.yaml @@ -0,0 +1,8 @@ +description: head -42 works with larger bare numeric shorthand. +input: + script: |+ + printf "1\n2\n3\n" | head -42 +expect: + stdout: "1\n2\n3\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/bare_number_shorthand_pipe.yaml b/tests/scenarios/cmd/head/lines/bare_number_shorthand_pipe.yaml new file mode 100644 index 00000000..352b08f8 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/bare_number_shorthand_pipe.yaml @@ -0,0 +1,8 @@ +description: head -5 works with piped input. +input: + script: |+ + printf "1\n2\n3\n4\n5\n6\n7\n" | head -5 +expect: + stdout: "1\n2\n3\n4\n5\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/head/lines/bare_number_shorthand_zero.yaml b/tests/scenarios/cmd/head/lines/bare_number_shorthand_zero.yaml new file mode 100644 index 00000000..2e038401 --- /dev/null +++ b/tests/scenarios/cmd/head/lines/bare_number_shorthand_zero.yaml @@ -0,0 +1,8 @@ +description: head -0 (bare zero shorthand) produces no output, equivalent to head -n 0. +input: + script: |+ + printf "1\n2\n3\n" | head -0 +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/tail/lines/bare_number_shorthand.yaml b/tests/scenarios/cmd/tail/lines/bare_number_shorthand.yaml new file mode 100644 index 00000000..87188746 --- /dev/null +++ b/tests/scenarios/cmd/tail/lines/bare_number_shorthand.yaml @@ -0,0 +1,13 @@ +description: tail -3 (bare numeric shorthand) outputs the last 3 lines, equivalent to tail -n 3. +setup: + files: + - path: file.txt + content: "alpha\nbeta\ngamma\ndelta\nepsilon\n" +input: + allowed_paths: ["$DIR"] + script: |+ + tail -3 file.txt +expect: + stdout: "gamma\ndelta\nepsilon\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/tail/lines/bare_number_shorthand_pipe.yaml b/tests/scenarios/cmd/tail/lines/bare_number_shorthand_pipe.yaml new file mode 100644 index 00000000..627d2ebb --- /dev/null +++ b/tests/scenarios/cmd/tail/lines/bare_number_shorthand_pipe.yaml @@ -0,0 +1,8 @@ +description: tail -5 works with piped input. +input: + script: |+ + printf "1\n2\n3\n4\n5\n6\n7\n" | tail -5 +expect: + stdout: "3\n4\n5\n6\n7\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/tail/lines/bare_number_shorthand_zero.yaml b/tests/scenarios/cmd/tail/lines/bare_number_shorthand_zero.yaml new file mode 100644 index 00000000..713498fa --- /dev/null +++ b/tests/scenarios/cmd/tail/lines/bare_number_shorthand_zero.yaml @@ -0,0 +1,8 @@ +description: tail -0 (bare zero shorthand) produces no output, equivalent to tail -n 0. +input: + script: |+ + printf "1\n2\n3\n" | tail -0 +expect: + stdout: "" + stderr: "" + exit_code: 0