diff --git a/parser.go b/parser.go index 9b30d64..b26538a 100644 --- a/parser.go +++ b/parser.go @@ -30,6 +30,24 @@ type Parser struct { SortFlagsReverse bool // when true with SortFlags, sort order is reversed (Z..A) } +// supportedCompletionShells lists every shell that can receive generated completion output. +var supportedCompletionShells = []string{"bash", "zsh", "fish", "powershell", "nushell"} + +// completionShellList joins the supported completion shell names into a space separated string. +func completionShellList() string { + return strings.Join(supportedCompletionShells, " ") +} + +// isSupportedCompletionShell reports whether the provided shell is eligible for generated completions. +func isSupportedCompletionShell(shell string) bool { + for _, supported := range supportedCompletionShells { + if shell == supported { + return true + } + } + return false +} + // TrailingSubcommand returns the last and most specific subcommand invoked. func (p *Parser) TrailingSubcommand() *Subcommand { return p.subcommandContext @@ -98,19 +116,17 @@ func (p *Parser) ParseArgs(args []string) error { if len(args) >= 1 && strings.EqualFold(args[0], "completion") { // no shell provided if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Please specify a shell for completion. Supported shells: bash zsh") + fmt.Fprintf(os.Stderr, "Please specify a shell for completion. Supported shells: %s\n", completionShellList()) exitOrPanic(2) } shell := strings.ToLower(args[1]) - switch shell { - case "bash", "zsh": + if isSupportedCompletionShell(shell) { p.Completion(shell) exitOrPanic(0) - default: - fmt.Fprintf(os.Stderr, "Unsupported shell specified for completion: %s\nSupported shells: bash zsh\n", args[1]) - exitOrPanic(2) } + fmt.Fprintf(os.Stderr, "Unsupported shell specified for completion: %s\nSupported shells: %s\n", args[1], completionShellList()) + exitOrPanic(2) } } @@ -141,13 +157,19 @@ func (p *Parser) ParseArgs(args []string) error { // Completion takes in a shell type and outputs the completion script for // that shell. func (p *Parser) Completion(completionType string) { - switch { - case strings.ToLower(completionType) == "bash": + switch strings.ToLower(completionType) { + case "bash": fmt.Print(GenerateBashCompletion(p)) - case strings.ToLower(completionType) == "zsh": + case "zsh": fmt.Print(GenerateZshCompletion(p)) + case "fish": + fmt.Print(GenerateFishCompletion(p)) + case "powershell": + fmt.Print(GeneratePowerShellCompletion(p)) + case "nushell": + fmt.Print(GenerateNushellCompletion(p)) default: - fmt.Fprintf(os.Stderr, "Unsupported shell specified for completion: %s\n", completionType) + fmt.Fprintf(os.Stderr, "Unsupported shell specified for completion: %s\nSupported shells: %s\n", completionType, completionShellList()) } } diff --git a/parser_completion_cli_test.go b/parser_completion_cli_test.go new file mode 100644 index 0000000..b35efc0 --- /dev/null +++ b/parser_completion_cli_test.go @@ -0,0 +1,123 @@ +package flaggy_test + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/integrii/flaggy" +) + +// runParserWithArgs executes a new parser using the provided os.Args tail while capturing +// stdout, stderr, and the panic payload triggered by exitOrPanic so tests can assert behavior. +func runParserWithArgs(t *testing.T, args []string) (string, string, any) { + t.Helper() + + originalArgs := os.Args + os.Args = append([]string{"starfleet"}, args...) + defer func() { + os.Args = originalArgs + }() + + parser := flaggy.NewParser("starfleet") + + originalStdout := os.Stdout + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + + originalStderr := os.Stderr + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stderr pipe: %v", err) + } + + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + defer func() { + os.Stdout = originalStdout + os.Stderr = originalStderr + }() + + originalPanicSetting := flaggy.PanicInsteadOfExit + flaggy.PanicInsteadOfExit = true + defer func() { + flaggy.PanicInsteadOfExit = originalPanicSetting + }() + + var recovered any + func() { + defer func() { + recovered = recover() + }() + _ = parser.Parse() + }() + + stdoutWriter.Close() + stderrWriter.Close() + + stdoutBytes, err := io.ReadAll(stdoutReader) + if err != nil { + t.Fatalf("failed to read stdout pipe: %v", err) + } + stderrBytes, err := io.ReadAll(stderrReader) + if err != nil { + t.Fatalf("failed to read stderr pipe: %v", err) + } + + stdoutReader.Close() + stderrReader.Close() + + return string(stdoutBytes), string(stderrBytes), recovered +} + +// TestParseCompletionRequiresShell ensures that invoking the completion command without a shell +// prints guidance that lists every supported shell so users know their options. +func TestParseCompletionRequiresShell(t *testing.T) { + _, stderr, recovered := runParserWithArgs(t, []string{"completion"}) + if recovered == nil { + t.Fatalf("expected parser to exit when no shell provided") + } + expectedExit := "Panic instead of exit with code: 2" + if recovered != expectedExit { + t.Fatalf("expected exit code 2 when shell missing: %v", recovered) + } + want := "Supported shells: bash zsh fish powershell nushell" + if !strings.Contains(stderr, want) { + t.Fatalf("expected stderr to describe all supported shells: %s", stderr) + } +} + +// TestParseCompletionSupportsAllShells confirms that every advertised completion shell runs +// through the parser and emits shell-specific output when provided on the command line. +func TestParseCompletionSupportsAllShells(t *testing.T) { + cases := []struct { + shell string + expected string + }{ + {shell: "bash", expected: "# bash completion"}, + {shell: "zsh", expected: "#compdef"}, + {shell: "fish", expected: "# fish completion"}, + {shell: "powershell", expected: "# PowerShell completion"}, + {shell: "nushell", expected: "# nushell completion"}, + } + + for _, tc := range cases { + stdout, stderr, recovered := runParserWithArgs(t, []string{"completion", tc.shell}) + if recovered == nil { + t.Fatalf("expected parser to exit after writing %s completion", tc.shell) + } + expectedExit := "Panic instead of exit with code: 0" + if recovered != expectedExit { + t.Fatalf("expected exit code 0 for %s completion: %v", tc.shell, recovered) + } + if len(stderr) > 0 { + t.Fatalf("expected stderr to be empty for %s completion: %s", tc.shell, stderr) + } + if !strings.Contains(stdout, tc.expected) { + t.Fatalf("expected stdout for %s completion to include %q: %s", tc.shell, tc.expected, stdout) + } + } +}