diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 18f44259..d449197c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,54 +1,96 @@ name: CI - on: [push, pull_request] - # Default to 'contents: read', which grants actions to read commits. Any # permission not included is implicitly set to "none". # # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions permissions: contents: read - jobs: test: name: Test runs-on: ubuntu-latest timeout-minutes: 20 # guardrails timeout - strategy: fail-fast: false matrix: go: ["1.12", "1.21", "1.22", "1.23", "oldstable", "stable"] - steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ matrix.go }} - - name: Test # Cannot enable shuffle for now because some tests rely on global state and order # run: go test -race -v -shuffle=on ./... run: go test -race -v ./... - lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 20 # guardrails timeout - steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: "stable" - - name: Lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: v2.7 + test-v2: + name: Test v2 + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ./v2 + strategy: + fail-fast: false + matrix: + go: [oldstable, stable] + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: ${{ matrix.go }} + - name: Lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: latest + working-directory: ./v2 + - name: Test + # Cannot enable shuffle for now because some tests rely on global state and order + # run: go test -race -v -shuffle=on ./... + run: go test -race -v ./... + conformance-v2: + name: Conformance v2 (flag drop-in + POSIX/GNU) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ./v2 + strategy: + fail-fast: false + matrix: + go: [oldstable, stable] + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: ${{ matrix.go }} + # The conformance suites (see v2/conformance/README.md) run the stdlib + # flag tests and the POSIX/GNU syntax tests against pflag v2. The two specs + # conflict by design (POSIX wins), so the oracle gates on the documented + # divergence catalogue rather than on a raw pass/fail: green iff failures + # match v2/conformance/divergences.json exactly. NOTE: this is expected to + # be red until v2 implements the flag API. + - name: Conformance (oracle gates on the divergence catalogue) + run: go test -tags conformance -json ./conformance | go run ./conformance/hack/oracle diff --git a/v2/conformance/README.md b/v2/conformance/README.md new file mode 100644 index 00000000..f4f3c276 --- /dev/null +++ b/v2/conformance/README.md @@ -0,0 +1,252 @@ +# Conformance suites + +pflag makes two compatibility promises, and this directory keeps both honest +with executable specs that run against pflag v2: + +1. **Drop-in replacement for the standard library `flag` package.** We run + `flag`'s *own* test suite against pflag v2 (dot-imported as `flag`) — + `flag_go*_test.go`. See [Standard-library `flag`](#standard-library-flag-suite). +2. **POSIX/GNU command-line argument syntax.** A hand-written suite keyed to the + POSIX Utility Syntax Guidelines and GNU extensions — `gnuposix_test.go`. See + [POSIX/GNU argument syntax](#posixgnu-argument-syntax-suite). + +Anything that fails to compile or fails to pass is, by definition, a gap. + +## POSIX wins, and the oracle gates on it + +The two specs genuinely conflict in a few places — single-dash `-int` is the flag +`int` to the stdlib, but the cluster `-i -n -t` to POSIX. **That conflict is the +whole reason pflag exists, and POSIX wins.** So some vendored stdlib tests are +*expected* to fail, and the stdlib suite can never be all-green. + +The success criterion is therefore not "everything passes" but: + +> The suite builds and runs, and the set of failing tests matches the documented +> divergence catalogue **exactly** — no undocumented failure (a regression) and +> no documented test that quietly started passing (a divergence to retire). + +That catalogue is [`divergences.json`](divergences.json) — the single source of +truth, listing each conflict, the POSIX Guideline / GNU rule that decides it, and +the exact tests it makes fail (categories: `posix-overrides-stdlib`, +`pflag-design-differs`, `pflag-omits-gnu-feature`). The oracle in +[`hack/oracle`](hack/oracle) reads `go test -json` and enforces the criterion +above; CI gates on it: + +```sh +go test -tags conformance -json ./conformance | go run ./conformance/hack/oracle +``` + +The oracle tells you exactly what to do when it's red: fix a regression, add a +newly-discovered conflict to the catalogue, or retire one that no longer applies. +That is how the catalogue gets **calibrated** against the real implementation — +the current entries are a best-effort seed. + +`go test ./...` (no tag) validates that `divergences.json` is well-formed +(`TestDivergenceManifest`) without needing v2 to compile. + +### Lifecycle: building v2 with a green CI + +The catalogue's top-level `status` and the `not-implemented-yet` category let the +job stay green while v2 is built out, tightening as it matures: + +| Phase | `status` | catalogue | oracle | +| --- | --- | --- | --- | +| v2 empty (now) | `bootstrapping` | permanent divergences only | build failure tolerated → **green** | +| v2 compiles, partial | `enforcing` | add `not-implemented-yet` entries for tests that fail only because a feature is missing | build required; those failures tolerated → **green** | +| v2 complete | `enforcing` | `not-implemented-yet` burned down to empty | only permanent divergences remain | + +`not-implemented-yet` is lenient: a listed test may fail, be skipped, or not run. +When one starts **passing**, the oracle stays green and just lists it as +*retirable* — delete the entry. (A *permanent* divergence that passes is the +opposite: a hard failure, because it never should.) So implementing a feature +never turns CI red; forgetting to categorise a new failure does. + +# Standard-library `flag` suite + +## Running it + +```sh +# from the v2 module root: +go test -tags conformance -v ./conformance/... + +# re-sync every vendored version (refreshes whatever is already present): +go generate ./conformance/... + +# add (or refresh) a specific version: +./conformance/hack/sync.sh 1.27 + +# drop a version — sync.sh never deletes, so remove its vendored file: +rm conformance/flag_go1.25_test.go +``` + +The set of `flag_go*_test.go` files is the source of truth for which versions +are vendored: `sync.sh ` adds/refreshes, deleting the file drops, and +`sync.sh` with no args re-syncs whatever remains. + +The suite only compiles under the `//go:build conformance` tag, so the normal +`go test ./...` run is unaffected while v2 is still being built out. + +## Go-version awareness + +A separate copy of upstream `flag_test.go` is vendored **per Go minor version**, +each pinned with a build constraint: + +| File | Build constraint | +| --- | --- | +| `flag_go1.25_test.go` | `conformance && go1.25 && !go1.26` | +| `flag_go1.26_test.go` | `conformance && go1.26 && !go1.27` | + +The toolchain running the tests therefore builds **exactly** the copy matching +its version. Two payoffs: + +1. The CI matrix (`oldstable`, `stable`) tests each Go release against *that + release's own* flag tests, so an upstream API or behavioral change in `flag` + shows up as a separate pass/fail signal. +2. Re-running `sync.sh` and reviewing the diff makes upstream changes between + versions visible in the repo. + +`harness_test.go` contains a guard (`TestVendoredStdlibFlagTest`) that **fails +loudly** if the running toolchain has no vendored copy — so adding a new Go +version to the CI matrix without syncing is caught, rather than silently testing +nothing. Each vendored copy registers its version with that guard via a small +generated `init()` injected just below its imports. + +## Layout + +The tests live in the external test package `conformance_test` (black-box tests +of v2's *public* API, mirroring upstream's `flag_test`); `doc.go` provides the +primary `conformance` package. + +| File | Origin | Edit policy | +| --- | --- | --- | +| `doc.go` | hand-written | package doc; keeps the dir buildable with no tags; holds the `//go:generate` directive | +| `flag_go_test.go` | generated from that version's `flag_test.go` | **do not edit** — regenerate with `sync.sh` | +| `harness_test.go` | hand-written | reimplements the stdlib's internal `export_test.go` helpers (`ResetForTesting`, `DefaultUsage`) against v2's public API, plus the version guard | +| `gnuposix_test.go` | hand-written | the POSIX/GNU argument-syntax suite | +| `divergences.json` | hand-written | the catalogue of intentional differences (single source of truth) | +| `divergences.go` / `divergences_test.go` | hand-written | embed + validate the catalogue (untagged, runs in normal `go test`) | +| `internal/divergence/` | hand-written | catalogue types + parsing, shared by the test and the oracle (no build tag, no v2 dependency) | +| `hack/oracle/` | hand-written | the CI gate: compares `go test -json` against the catalogue | +| `internal/testenv/testenv.go` | hand-written | minimal stand-in for the stdlib-internal `internal/testenv` (only `MustHaveExec` / `Executable`) | +| `hack/sync.sh` | hand-written | regenerates the vendored copies | + +### Why a sync script instead of a verbatim copy? + +External package conventions and stdlib-internal imports force a few mechanical +edits, which the script applies and nothing else: + +- `package flag_test` → `package conformance_test` +- `. "flag"` → `. "github.com/spf13/pflag/v2"` (kept a dot import) +- `"internal/testenv"` → the local shim (not importable outside the Go tree) +- inject a one-line `init()` after the imports registering the version with the guard +- prepend the build tags + a "generated" banner + +# POSIX/GNU argument syntax suite + +`gnuposix_test.go` checks pflag's *other* promise: compatibility with the POSIX +Utility Syntax Guidelines and the GNU long-option extensions. Each test cites the +rule it covers — `Gn` for a numbered POSIX Guideline, or "GNU" for an extension: + +| Area | Rules covered | +| --- | --- | +| Short options | preceded by `-` (G4), single alphanumeric name (G3), clustering (G5), separate or attached arg (G6), cluster ending in an arg-taking option (G5) | +| Long options (GNU) | `--name`, dashes in names, `--name=value`, `--name value` | +| Special tokens | `--` ends options (G10), lone `-` is an operand (G13) | +| Ordering | GNU interspersing (default) vs strict POSIX stop-at-first-operand (G9), order-independence and repetition (G11) | + +### Why hand-written instead of vendored? + +Unlike the stdlib `flag` suite, there is **no reusable Go-native POSIX +conformance corpus to vendor**. The authoritative sources are prose — the +[Open Group Utility Conventions ch. 12][posix] (Guidelines 1–14) and +[GNU Argument Syntax][gnu] — and the machine-runnable suites (glibc/gnulib +`tst-getopt*.c`) are C, coupled to the C `getopt`/optstring API, so they mostly +exercise C-isms pflag does not share. The tests are therefore authored directly +from the guidelines. They are shaped as *parse → normalized result*, so if a +shared cross-language corpus ever appears we can expose a tiny CLI to drive it +without restructuring. + +[posix]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html +[gnu]: https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html + +For a version not matching the local toolchain, `sync.sh` fetches `flag_test.go` +from the matching Go release branch on GitHub. + +--- + +## The gap list (TODO for the v2 implementation) + +This is the API the conformance suite requires. As of writing, v2 is an empty +package, so **all of it** is outstanding — that is intended; the suite is the +spec. Check items off as `go test -tags conformance ./conformance/...` gets +further. + +### 1. Package-level API surface + +Constructors / globals: + +- [ ] `type FlagSet` (and a usable zero value — `var flags FlagSet` is used) +- [ ] `type Flag struct { Name string; Usage string; Value Value; DefValue string }` +- [ ] `type Value interface { String() string; Set(string) error }` +- [ ] `type Getter interface { Value; Get() any }` +- [ ] `type ErrorHandling int` with `ContinueOnError`, `ExitOnError` (and `PanicOnError`) +- [ ] `var CommandLine *FlagSet` (must be assignable — `ResetForTesting` replaces it) +- [ ] `var Usage func()` (assignable package var) +- [ ] `var ErrHelp error` +- [ ] `func NewFlagSet(name string, errorHandling ErrorHandling) *FlagSet` + +Package-level convenience wrappers over `CommandLine`: + +- [ ] `Bool` `Int` `Int64` `Uint` `Uint64` `String` `Float64` `Duration` +- [ ] `Func` `BoolFunc` +- [ ] `Set` `Parse` `Visit` `VisitAll` `Arg` `Args` + +### 2. `*FlagSet` methods + +- [ ] Definers: `Bool` `BoolVar` `Int` `IntVar` `Int64` `Uint` `Uint64` `String` + `Float64` `Duration` `Var` `Func` `BoolFunc` +- [ ] Parsing: `Parse` `Parsed` +- [ ] Args: `Args` `Arg` +- [ ] Mutation/inspection: `Set` `Visit` `VisitAll` +- [ ] Config/identity: `Init` `Name` `ErrorHandling` `SetOutput` `Output` +- [ ] Usage: `PrintDefaults`, exported `Usage func()` field +- [ ] `Var` flags must populate `Flag.DefValue` from `Value.String()` + +### 3. Behavioral conformance (won't show as compile errors — the hard part) + +Once the API compiles, these stdlib tests assert behavior that differs from +pflag v1's POSIX/GNU conventions. Each needs a deliberate decision: match stdlib, +or document as an accepted divergence and adjust/skip the assertion. + +- [ ] **Single-dash long flags.** `testParse` passes `-bool`, `-int`, `-uint`, + `-string`, `-float64`, `-duration` (single dash, long name). pflag v1 reads + `-int` as the shorthand cluster `-i -n -t`. This is the single biggest + drop-in gap and affects `TestParse`, `TestFlagSetParse`, `TestUserDefined*`, + `TestHelp`, `TestExitCode`. +- [ ] **`-flag=value` and `-flag value` forms** for all types (`-bool2=true`, + `--int 22`). +- [ ] **Hex/base-0 integer parsing.** `testParse` sets `--int64 0x23` and expects + `0x23`. Int/Int64/Uint/Uint64 must parse with base 0. +- [ ] **`PrintDefaults` exact format.** `TestPrintDefaults` and + `TestUserDefinedBoolUsage` compare byte-for-byte against the stdlib layout + (` -A\tfor ...`, back-quoted `name` extraction, multiline indent, panic + recovery for a `String()` that panics on a zero value). +- [ ] **Error message text.** `TestParseError` wants `invalid` + `parse error`; + `TestRangeError` wants `invalid` + `value out of range`; `TestUsageOutput` + wants exactly `flag provided but not defined: -i\nUsage of app:\n`. +- [ ] **`Var` validation panics.** `TestInvalidFlags`: name starting with `-` + panics `flag "-foo" begins with -`; name containing `=` panics + `flag "foo=bar" contains =`. `TestRedefinedFlags`: `flag redefined: foo`. +- [ ] **Define-after-set panic.** `TestDefineAfterSet` expects a panic matching + `flag myFlag set at .*:.* before being defined`. +- [ ] **`Getter` semantics.** `TestGet`: every built-in value satisfies `Getter` + and `Get()` returns the natural Go type (`bool`, `int`, `int64`, `uint`, + `uint64`, `string`, `float64`, `time.Duration`). The `Func` value's + `String()` returns `""` and it is **not** a `Getter`. +- [ ] **`IsBoolFlag` user types.** `TestUserDefinedBool` relies on a custom + `Value` with `IsBoolFlag()` toggling between bool-like and value-like + parsing mid-parse. +- [ ] **`-h`/`-help` ⇒ `ErrHelp`** when undefined, overridable by a defined flag + (`TestHelp`), and exit codes 0/2/magic (`TestExitCode`, runs a child proc). +- [ ] **Int overflow** is a parse error (`TestIntFlagOverflow`, 32-bit only). +- [ ] **Visit ordering** is lexical/sorted (`TestEverything`). diff --git a/v2/conformance/divergences.go b/v2/conformance/divergences.go new file mode 100644 index 00000000..d414e1c4 --- /dev/null +++ b/v2/conformance/divergences.go @@ -0,0 +1,18 @@ +package conformance + +import ( + _ "embed" + + "github.com/spf13/pflag/v2/conformance/internal/divergence" +) + +//go:embed divergences.json +var manifestJSON []byte + +// Manifest returns the parsed divergence catalogue (divergences.json): the +// documented, intentional differences between pflag v2 and the stdlib flag +// package. The conformance oracle (hack/oracle) uses it to decide which test +// failures are expected. +func Manifest() (divergence.Manifest, error) { + return divergence.Parse(manifestJSON) +} diff --git a/v2/conformance/divergences.json b/v2/conformance/divergences.json new file mode 100644 index 00000000..460e2d5c --- /dev/null +++ b/v2/conformance/divergences.json @@ -0,0 +1,74 @@ +{ + "_comment": "Catalogue of intentional differences between pflag v2 and the standard library flag package. Single source of truth for both the human-readable docs and the conformance oracle (hack/oracle). Each entry lists the conformance tests it is expected to make fail. POSIX wins. This catalogue is a best-effort seed and MUST be calibrated against the real v2 implementation: the oracle reports any undocumented failure (a regression) and any permanent divergence that unexpectedly passes (to remove). Categories: posix-overrides-stdlib | pflag-design-differs | pflag-omits-gnu-feature | not-implemented-yet (temporary, burned down as v2 is built). The 'status' field gates build failures: 'bootstrapping' tolerates the suite not building yet (CI green during build-out); flip to 'enforcing' once v2 compiles.", + "status": "bootstrapping", + "divergences": [ + { + "category": "posix-overrides-stdlib", + "topic": "single-dash tokens are short-option clusters; long names need the GNU \"--\" prefix", + "stdlib": "A single dash introduces a (possibly multi-character) flag name: \"-bool\", \"-int 22\", \"-help\".", + "pflag": "A single dash introduces one or more single-character options, so \"-int\" is the cluster -i -n -t; multi-character names require \"--\".", + "refs": "POSIX Guidelines 3, 4, 5, 14; GNU long-option extension", + "affectedTests": [ + "TestParse", + "TestFlagSetParse", + "TestUserDefined", + "TestUserDefinedFunc", + "TestUserDefinedBool", + "TestHelp", + "TestExitCode" + ] + }, + { + "category": "pflag-design-differs", + "topic": "usage / PrintDefaults output uses the \"-s, --long\" layout", + "stdlib": "PrintDefaults emits \" -A\\tfor ...\" (single dash, tab-aligned) and the tests compare it byte-for-byte.", + "pflag": "Help reflects POSIX/GNU spelling (short and long forms together); the exact byte layout differs from the stdlib.", + "refs": "GNU usage convention", + "affectedTests": [ + "TestPrintDefaults", + "TestUserDefinedBoolUsage" + ] + }, + { + "category": "pflag-design-differs", + "topic": "parse-error and usage message text differs from the stdlib", + "stdlib": "Errors read e.g. \"flag provided but not defined: -i\" and \"invalid value ... parse error\".", + "pflag": "pflag has its own error wording, so byte/substring comparisons against the stdlib text do not hold.", + "refs": "implementation detail (not mandated by POSIX)", + "affectedTests": [ + "TestUsageOutput", + "TestParseError" + ] + }, + { + "category": "pflag-design-differs", + "topic": "no \"flag set before being defined\" panic", + "stdlib": "Defining a flag after Set was called for that name panics with \"flag X set at ... before being defined\".", + "pflag": "pflag does not track set-before-define; Set on an unknown flag returns an error and defining later does not panic.", + "refs": "implementation detail (not mandated by POSIX)", + "affectedTests": [ + "TestDefineAfterSet" + ] + }, + { + "category": "pflag-design-differs", + "topic": "flag-name validation panics with different messages", + "stdlib": "Var panics with \"flag \\\"-foo\\\" begins with -\" / \"flag \\\"foo=bar\\\" contains =\".", + "pflag": "pflag validates names differently and does not produce the same panic messages.", + "refs": "implementation detail (not mandated by POSIX)", + "affectedTests": [ + "TestInvalidFlags" + ] + }, + { + "category": "pflag-omits-gnu-feature", + "topic": "unambiguous long-option abbreviation", + "stdlib": "n/a", + "pflag": "GNU accepts \"--verb\" for \"--verbose\" when unambiguous; pflag requires the full long name.", + "refs": "GNU abbreviation rule", + "affectedTests": [ + "TestLongOptionAbbreviation" + ] + } + ] +} diff --git a/v2/conformance/divergences_test.go b/v2/conformance/divergences_test.go new file mode 100644 index 00000000..f5b0bdc2 --- /dev/null +++ b/v2/conformance/divergences_test.go @@ -0,0 +1,23 @@ +package conformance + +// This is an untagged, in-package test so it runs in the normal `go test ./...` +// (it does not need v2 to compile). It validates the divergence catalogue +// (divergences.json) and prints it under -v. The catalogue is the single source +// of truth shared with the conformance oracle (hack/oracle); the oracle uses it +// to treat exactly these test failures as expected when gating CI. + +import "testing" + +func TestDivergenceManifest(t *testing.T) { + m, err := Manifest() + if err != nil { + t.Fatal(err) + } + if err := m.Validate(); err != nil { + t.Fatalf("divergences.json is invalid: %v", err) + } + for _, d := range m.Divergences { + t.Logf("[%s] %s\n stdlib: %s\n pflag : %s\n refs : %s\n affected: %v", + d.Category, d.Topic, d.Stdlib, d.Pflag, d.Refs, d.AffectedTests) + } +} diff --git a/v2/conformance/doc.go b/v2/conformance/doc.go new file mode 100644 index 00000000..ee14016a --- /dev/null +++ b/v2/conformance/doc.go @@ -0,0 +1,29 @@ +// Package conformance verifies pflag v2's two compatibility promises: +// +// - Drop-in replacement for the Go standard library flag package. Tested by +// vendored, build-tagged copies of the stdlib flag_test.go, one per +// supported Go version (flag_go*_test.go). +// - POSIX/GNU command-line argument syntax. Tested by a hand-written suite +// keyed to the POSIX Utility Syntax Guidelines and GNU extensions +// (gnuposix_test.go). +// +// The two specs genuinely conflict in a few places — that is why pflag exists. +// POSIX wins; those conflicts are catalogued in divergences.json and enforced by +// the oracle (hack/oracle), which gates CI: the suite is green iff the failing +// tests match the catalogue exactly. +// +// Everything here only compiles under the "conformance" build tag, so it does +// not affect the normal `go test ./...` run while v2 is still being built out. +// See README.md for details and run them with: +// +// go test -tags conformance ./conformance/... +// +// This file exists so the directory always contains at least one buildable Go +// file regardless of build tags. +// +// Run `go generate ./conformance/...` to re-sync the vendored stdlib flag tests +// (it refreshes every version already vendored here; pass explicit versions to +// hack/sync.sh to add or drop one). +// +//go:generate ./hack/sync.sh +package conformance diff --git a/v2/conformance/flag_go1.25_test.go b/v2/conformance/flag_go1.25_test.go new file mode 100644 index 00000000..62736b2e --- /dev/null +++ b/v2/conformance/flag_go1.25_test.go @@ -0,0 +1,875 @@ +//go:build conformance && go1.25 && !go1.26 + +// Code generated by conformance/hack/sync.sh from the Go standard library's +// flag package test suite (src/flag/flag_test.go) at go1.25. DO NOT EDIT. +// +// This is upstream flag_test.go run against pflag v2 (dot-imported as "flag") to +// verify the drop-in-replacement promise. The build constraint pins it to the +// go1.25 toolchain so the CI matrix tests each supported Go release against +// that release's own flag tests. The only changes from upstream are the package +// clause, the dot-import target, the internal/testenv import, and a generated +// init() (just below the imports) that registers this copy with the guard in +// harness_test.go. Refresh with conformance/hack/sync.sh. + +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package conformance_test + +import ( + "bytes" + "fmt" + . "github.com/spf13/pflag/v2" + "github.com/spf13/pflag/v2/conformance/internal/testenv" + "io" + "os" + "os/exec" + "regexp" + "runtime" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +// Register this vendored copy with the guard in harness_test.go so an +// un-synced Go toolchain fails loudly instead of silently testing nothing. +func init() { vendoredVersion = "go1.25" } + +func boolString(s string) string { + if s == "0" { + return "false" + } + return "true" +} + +func TestEverything(t *testing.T) { + ResetForTesting(nil) + Bool("test_bool", false, "bool value") + Int("test_int", 0, "int value") + Int64("test_int64", 0, "int64 value") + Uint("test_uint", 0, "uint value") + Uint64("test_uint64", 0, "uint64 value") + String("test_string", "0", "string value") + Float64("test_float64", 0, "float64 value") + Duration("test_duration", 0, "time.Duration value") + Func("test_func", "func value", func(string) error { return nil }) + BoolFunc("test_boolfunc", "func", func(string) error { return nil }) + + m := make(map[string]*Flag) + desired := "0" + visitor := func(f *Flag) { + if len(f.Name) > 5 && f.Name[0:5] == "test_" { + m[f.Name] = f + ok := false + switch { + case f.Value.String() == desired: + ok = true + case f.Name == "test_bool" && f.Value.String() == boolString(desired): + ok = true + case f.Name == "test_duration" && f.Value.String() == desired+"s": + ok = true + case f.Name == "test_func" && f.Value.String() == "": + ok = true + case f.Name == "test_boolfunc" && f.Value.String() == "": + ok = true + } + if !ok { + t.Error("Visit: bad value", f.Value.String(), "for", f.Name) + } + } + } + VisitAll(visitor) + if len(m) != 10 { + t.Error("VisitAll misses some flags") + for k, v := range m { + t.Log(k, *v) + } + } + m = make(map[string]*Flag) + Visit(visitor) + if len(m) != 0 { + t.Errorf("Visit sees unset flags") + for k, v := range m { + t.Log(k, *v) + } + } + // Now set all flags + Set("test_bool", "true") + Set("test_int", "1") + Set("test_int64", "1") + Set("test_uint", "1") + Set("test_uint64", "1") + Set("test_string", "1") + Set("test_float64", "1") + Set("test_duration", "1s") + Set("test_func", "1") + Set("test_boolfunc", "") + desired = "1" + Visit(visitor) + if len(m) != 10 { + t.Error("Visit fails after set") + for k, v := range m { + t.Log(k, *v) + } + } + // Now test they're visited in sort order. + var flagNames []string + Visit(func(f *Flag) { flagNames = append(flagNames, f.Name) }) + if !slices.IsSorted(flagNames) { + t.Errorf("flag names not sorted: %v", flagNames) + } +} + +func TestGet(t *testing.T) { + ResetForTesting(nil) + Bool("test_bool", true, "bool value") + Int("test_int", 1, "int value") + Int64("test_int64", 2, "int64 value") + Uint("test_uint", 3, "uint value") + Uint64("test_uint64", 4, "uint64 value") + String("test_string", "5", "string value") + Float64("test_float64", 6, "float64 value") + Duration("test_duration", 7, "time.Duration value") + + visitor := func(f *Flag) { + if len(f.Name) > 5 && f.Name[0:5] == "test_" { + g, ok := f.Value.(Getter) + if !ok { + t.Errorf("Visit: value does not satisfy Getter: %T", f.Value) + return + } + switch f.Name { + case "test_bool": + ok = g.Get() == true + case "test_int": + ok = g.Get() == int(1) + case "test_int64": + ok = g.Get() == int64(2) + case "test_uint": + ok = g.Get() == uint(3) + case "test_uint64": + ok = g.Get() == uint64(4) + case "test_string": + ok = g.Get() == "5" + case "test_float64": + ok = g.Get() == float64(6) + case "test_duration": + ok = g.Get() == time.Duration(7) + } + if !ok { + t.Errorf("Visit: bad value %T(%v) for %s", g.Get(), g.Get(), f.Name) + } + } + } + VisitAll(visitor) +} + +func TestUsage(t *testing.T) { + called := false + ResetForTesting(func() { called = true }) + if CommandLine.Parse([]string{"-x"}) == nil { + t.Error("parse did not fail for unknown flag") + } + if !called { + t.Error("did not call Usage for unknown flag") + } +} + +func testParse(f *FlagSet, t *testing.T) { + if f.Parsed() { + t.Error("f.Parse() = true before Parse") + } + boolFlag := f.Bool("bool", false, "bool value") + bool2Flag := f.Bool("bool2", false, "bool2 value") + intFlag := f.Int("int", 0, "int value") + int64Flag := f.Int64("int64", 0, "int64 value") + uintFlag := f.Uint("uint", 0, "uint value") + uint64Flag := f.Uint64("uint64", 0, "uint64 value") + stringFlag := f.String("string", "0", "string value") + float64Flag := f.Float64("float64", 0, "float64 value") + durationFlag := f.Duration("duration", 5*time.Second, "time.Duration value") + extra := "one-extra-argument" + args := []string{ + "-bool", + "-bool2=true", + "--int", "22", + "--int64", "0x23", + "-uint", "24", + "--uint64", "25", + "-string", "hello", + "-float64", "2718e28", + "-duration", "2m", + extra, + } + if err := f.Parse(args); err != nil { + t.Fatal(err) + } + if !f.Parsed() { + t.Error("f.Parse() = false after Parse") + } + if *boolFlag != true { + t.Error("bool flag should be true, is ", *boolFlag) + } + if *bool2Flag != true { + t.Error("bool2 flag should be true, is ", *bool2Flag) + } + if *intFlag != 22 { + t.Error("int flag should be 22, is ", *intFlag) + } + if *int64Flag != 0x23 { + t.Error("int64 flag should be 0x23, is ", *int64Flag) + } + if *uintFlag != 24 { + t.Error("uint flag should be 24, is ", *uintFlag) + } + if *uint64Flag != 25 { + t.Error("uint64 flag should be 25, is ", *uint64Flag) + } + if *stringFlag != "hello" { + t.Error("string flag should be `hello`, is ", *stringFlag) + } + if *float64Flag != 2718e28 { + t.Error("float64 flag should be 2718e28, is ", *float64Flag) + } + if *durationFlag != 2*time.Minute { + t.Error("duration flag should be 2m, is ", *durationFlag) + } + if len(f.Args()) != 1 { + t.Error("expected one argument, got", len(f.Args())) + } else if f.Args()[0] != extra { + t.Errorf("expected argument %q got %q", extra, f.Args()[0]) + } +} + +func TestParse(t *testing.T) { + ResetForTesting(func() { t.Error("bad parse") }) + testParse(CommandLine, t) +} + +func TestFlagSetParse(t *testing.T) { + testParse(NewFlagSet("test", ContinueOnError), t) +} + +// Declare a user-defined flag type. +type flagVar []string + +func (f *flagVar) String() string { + return fmt.Sprint([]string(*f)) +} + +func (f *flagVar) Set(value string) error { + *f = append(*f, value) + return nil +} + +func TestUserDefined(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + flags.SetOutput(io.Discard) + var v flagVar + flags.Var(&v, "v", "usage") + if err := flags.Parse([]string{"-v", "1", "-v", "2", "-v=3"}); err != nil { + t.Error(err) + } + if len(v) != 3 { + t.Fatal("expected 3 args; got ", len(v)) + } + expect := "[1 2 3]" + if v.String() != expect { + t.Errorf("expected value %q got %q", expect, v.String()) + } +} + +func TestUserDefinedFunc(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + var ss []string + flags.Func("v", "usage", func(s string) error { + ss = append(ss, s) + return nil + }) + if err := flags.Parse([]string{"-v", "1", "-v", "2", "-v=3"}); err != nil { + t.Error(err) + } + if len(ss) != 3 { + t.Fatal("expected 3 args; got ", len(ss)) + } + expect := "[1 2 3]" + if got := fmt.Sprint(ss); got != expect { + t.Errorf("expected value %q got %q", expect, got) + } + // test usage + var buf strings.Builder + flags.SetOutput(&buf) + flags.Parse([]string{"-h"}) + if usage := buf.String(); !strings.Contains(usage, "usage") { + t.Errorf("usage string not included: %q", usage) + } + // test Func error + flags = NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + flags.Func("v", "usage", func(s string) error { + return fmt.Errorf("test error") + }) + // flag not set, so no error + if err := flags.Parse(nil); err != nil { + t.Error(err) + } + // flag set, expect error + if err := flags.Parse([]string{"-v", "1"}); err == nil { + t.Error("expected error; got none") + } else if errMsg := err.Error(); !strings.Contains(errMsg, "test error") { + t.Errorf(`error should contain "test error"; got %q`, errMsg) + } +} + +func TestUserDefinedForCommandLine(t *testing.T) { + const help = "HELP" + var result string + ResetForTesting(func() { result = help }) + Usage() + if result != help { + t.Fatalf("got %q; expected %q", result, help) + } +} + +// Declare a user-defined boolean flag type. +type boolFlagVar struct { + count int +} + +func (b *boolFlagVar) String() string { + return fmt.Sprintf("%d", b.count) +} + +func (b *boolFlagVar) Set(value string) error { + if value == "true" { + b.count++ + } + return nil +} + +func (b *boolFlagVar) IsBoolFlag() bool { + return b.count < 4 +} + +func TestUserDefinedBool(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + flags.SetOutput(io.Discard) + var b boolFlagVar + var err error + flags.Var(&b, "b", "usage") + if err = flags.Parse([]string{"-b", "-b", "-b", "-b=true", "-b=false", "-b", "barg", "-b"}); err != nil { + if b.count < 4 { + t.Error(err) + } + } + + if b.count != 4 { + t.Errorf("want: %d; got: %d", 4, b.count) + } + + if err == nil { + t.Error("expected error; got none") + } +} + +func TestUserDefinedBoolUsage(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + var buf bytes.Buffer + flags.SetOutput(&buf) + var b boolFlagVar + flags.Var(&b, "b", "X") + b.count = 0 + // b.IsBoolFlag() will return true and usage will look boolean. + flags.PrintDefaults() + got := buf.String() + want := " -b\tX\n" + if got != want { + t.Errorf("false: want %q; got %q", want, got) + } + b.count = 4 + // b.IsBoolFlag() will return false and usage will look non-boolean. + flags.PrintDefaults() + got = buf.String() + want = " -b\tX\n -b value\n \tX\n" + if got != want { + t.Errorf("false: want %q; got %q", want, got) + } +} + +func TestSetOutput(t *testing.T) { + var flags FlagSet + var buf strings.Builder + flags.SetOutput(&buf) + flags.Init("test", ContinueOnError) + flags.Parse([]string{"-unknown"}) + if out := buf.String(); !strings.Contains(out, "-unknown") { + t.Logf("expected output mentioning unknown; got %q", out) + } +} + +// This tests that one can reset the flags. This still works but not well, and is +// superseded by FlagSet. +func TestChangingArgs(t *testing.T) { + ResetForTesting(func() { t.Fatal("bad parse") }) + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"cmd", "-before", "subcmd", "-after", "args"} + before := Bool("before", false, "") + if err := CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + cmd := Arg(0) + os.Args = Args() + after := Bool("after", false, "") + Parse() + args := Args() + + if !*before || cmd != "subcmd" || !*after || len(args) != 1 || args[0] != "args" { + t.Fatalf("expected true subcmd true [args] got %v %v %v %v", *before, cmd, *after, args) + } +} + +// Test that -help invokes the usage message and returns ErrHelp. +func TestHelp(t *testing.T) { + var helpCalled = false + fs := NewFlagSet("help test", ContinueOnError) + fs.Usage = func() { helpCalled = true } + var flag bool + fs.BoolVar(&flag, "flag", false, "regular flag") + // Regular flag invocation should work + err := fs.Parse([]string{"-flag=true"}) + if err != nil { + t.Fatal("expected no error; got ", err) + } + if !flag { + t.Error("flag was not set by -flag") + } + if helpCalled { + t.Error("help called for regular flag") + helpCalled = false // reset for next test + } + // Help flag should work as expected. + err = fs.Parse([]string{"-help"}) + if err == nil { + t.Fatal("error expected") + } + if err != ErrHelp { + t.Fatal("expected ErrHelp; got ", err) + } + if !helpCalled { + t.Fatal("help was not called") + } + // If we define a help flag, that should override. + var help bool + fs.BoolVar(&help, "help", false, "help flag") + helpCalled = false + err = fs.Parse([]string{"-help"}) + if err != nil { + t.Fatal("expected no error for defined -help; got ", err) + } + if helpCalled { + t.Fatal("help was called; should not have been for defined help flag") + } +} + +// zeroPanicker is a flag.Value whose String method panics if its dontPanic +// field is false. +type zeroPanicker struct { + dontPanic bool + v string +} + +func (f *zeroPanicker) Set(s string) error { + f.v = s + return nil +} + +func (f *zeroPanicker) String() string { + if !f.dontPanic { + panic("panic!") + } + return f.v +} + +const defaultOutput = ` -A for bootstrapping, allow 'any' type + -Alongflagname + disable bounds checking + -C a boolean defaulting to true (default true) + -D path + set relative path for local imports + -E string + issue 23543 (default "0") + -F number + a non-zero number (default 2.7) + -G float + a float that defaults to zero + -M string + a multiline + help + string + -N int + a non-zero int (default 27) + -O a flag + multiline help string (default true) + -V list + a list of strings (default [a b]) + -Z int + an int that defaults to zero + -ZP0 value + a flag whose String method panics when it is zero + -ZP1 value + a flag whose String method panics when it is zero + -maxT timeout + set timeout for dial + +panic calling String method on zero flag_test.zeroPanicker for flag ZP0: panic! +panic calling String method on zero flag_test.zeroPanicker for flag ZP1: panic! +` + +func TestPrintDefaults(t *testing.T) { + fs := NewFlagSet("print defaults test", ContinueOnError) + var buf strings.Builder + fs.SetOutput(&buf) + fs.Bool("A", false, "for bootstrapping, allow 'any' type") + fs.Bool("Alongflagname", false, "disable bounds checking") + fs.Bool("C", true, "a boolean defaulting to true") + fs.String("D", "", "set relative `path` for local imports") + fs.String("E", "0", "issue 23543") + fs.Float64("F", 2.7, "a non-zero `number`") + fs.Float64("G", 0, "a float that defaults to zero") + fs.String("M", "", "a multiline\nhelp\nstring") + fs.Int("N", 27, "a non-zero int") + fs.Bool("O", true, "a flag\nmultiline help string") + fs.Var(&flagVar{"a", "b"}, "V", "a `list` of strings") + fs.Int("Z", 0, "an int that defaults to zero") + fs.Var(&zeroPanicker{true, ""}, "ZP0", "a flag whose String method panics when it is zero") + fs.Var(&zeroPanicker{true, "something"}, "ZP1", "a flag whose String method panics when it is zero") + fs.Duration("maxT", 0, "set `timeout` for dial") + fs.PrintDefaults() + got := buf.String() + if got != defaultOutput { + t.Errorf("got:\n%q\nwant:\n%q", got, defaultOutput) + } +} + +// Issue 19230: validate range of Int and Uint flag values. +func TestIntFlagOverflow(t *testing.T) { + if strconv.IntSize != 32 { + return + } + ResetForTesting(nil) + Int("i", 0, "") + Uint("u", 0, "") + if err := Set("i", "2147483648"); err == nil { + t.Error("unexpected success setting Int") + } + if err := Set("u", "4294967296"); err == nil { + t.Error("unexpected success setting Uint") + } +} + +// Issue 20998: Usage should respect CommandLine.output. +func TestUsageOutput(t *testing.T) { + ResetForTesting(DefaultUsage) + var buf strings.Builder + CommandLine.SetOutput(&buf) + defer func(old []string) { os.Args = old }(os.Args) + os.Args = []string{"app", "-i=1", "-unknown"} + Parse() + const want = "flag provided but not defined: -i\nUsage of app:\n" + if got := buf.String(); got != want { + t.Errorf("output = %q; want %q", got, want) + } +} + +func TestGetters(t *testing.T) { + expectedName := "flag set" + expectedErrorHandling := ContinueOnError + expectedOutput := io.Writer(os.Stderr) + fs := NewFlagSet(expectedName, expectedErrorHandling) + + if fs.Name() != expectedName { + t.Errorf("unexpected name: got %s, expected %s", fs.Name(), expectedName) + } + if fs.ErrorHandling() != expectedErrorHandling { + t.Errorf("unexpected ErrorHandling: got %d, expected %d", fs.ErrorHandling(), expectedErrorHandling) + } + if fs.Output() != expectedOutput { + t.Errorf("unexpected output: got %#v, expected %#v", fs.Output(), expectedOutput) + } + + expectedName = "gopher" + expectedErrorHandling = ExitOnError + expectedOutput = os.Stdout + fs.Init(expectedName, expectedErrorHandling) + fs.SetOutput(expectedOutput) + + if fs.Name() != expectedName { + t.Errorf("unexpected name: got %s, expected %s", fs.Name(), expectedName) + } + if fs.ErrorHandling() != expectedErrorHandling { + t.Errorf("unexpected ErrorHandling: got %d, expected %d", fs.ErrorHandling(), expectedErrorHandling) + } + if fs.Output() != expectedOutput { + t.Errorf("unexpected output: got %v, expected %v", fs.Output(), expectedOutput) + } +} + +func TestParseError(t *testing.T) { + for _, typ := range []string{"bool", "int", "int64", "uint", "uint64", "float64", "duration"} { + fs := NewFlagSet("parse error test", ContinueOnError) + fs.SetOutput(io.Discard) + _ = fs.Bool("bool", false, "") + _ = fs.Int("int", 0, "") + _ = fs.Int64("int64", 0, "") + _ = fs.Uint("uint", 0, "") + _ = fs.Uint64("uint64", 0, "") + _ = fs.Float64("float64", 0, "") + _ = fs.Duration("duration", 0, "") + // Strings cannot give errors. + args := []string{"-" + typ + "=x"} + err := fs.Parse(args) // x is not a valid setting for any flag. + if err == nil { + t.Errorf("Parse(%q)=%v; expected parse error", args, err) + continue + } + if !strings.Contains(err.Error(), "invalid") || !strings.Contains(err.Error(), "parse error") { + t.Errorf("Parse(%q)=%v; expected parse error", args, err) + } + } +} + +func TestRangeError(t *testing.T) { + bad := []string{ + "-int=123456789012345678901", + "-int64=123456789012345678901", + "-uint=123456789012345678901", + "-uint64=123456789012345678901", + "-float64=1e1000", + } + for _, arg := range bad { + fs := NewFlagSet("parse error test", ContinueOnError) + fs.SetOutput(io.Discard) + _ = fs.Int("int", 0, "") + _ = fs.Int64("int64", 0, "") + _ = fs.Uint("uint", 0, "") + _ = fs.Uint64("uint64", 0, "") + _ = fs.Float64("float64", 0, "") + // Strings cannot give errors, and bools and durations do not return strconv.NumError. + err := fs.Parse([]string{arg}) + if err == nil { + t.Errorf("Parse(%q)=%v; expected range error", arg, err) + continue + } + if !strings.Contains(err.Error(), "invalid") || !strings.Contains(err.Error(), "value out of range") { + t.Errorf("Parse(%q)=%v; expected range error", arg, err) + } + } +} + +func TestExitCode(t *testing.T) { + testenv.MustHaveExec(t) + + magic := 123 + if os.Getenv("GO_CHILD_FLAG") != "" { + fs := NewFlagSet("test", ExitOnError) + if os.Getenv("GO_CHILD_FLAG_HANDLE") != "" { + var b bool + fs.BoolVar(&b, os.Getenv("GO_CHILD_FLAG_HANDLE"), false, "") + } + fs.Parse([]string{os.Getenv("GO_CHILD_FLAG")}) + os.Exit(magic) + } + + tests := []struct { + flag string + flagHandle string + expectExit int + }{ + { + flag: "-h", + expectExit: 0, + }, + { + flag: "-help", + expectExit: 0, + }, + { + flag: "-undefined", + expectExit: 2, + }, + { + flag: "-h", + flagHandle: "h", + expectExit: magic, + }, + { + flag: "-help", + flagHandle: "help", + expectExit: magic, + }, + } + + for _, test := range tests { + cmd := exec.Command(testenv.Executable(t), "-test.run=^TestExitCode$") + cmd.Env = append( + os.Environ(), + "GO_CHILD_FLAG="+test.flag, + "GO_CHILD_FLAG_HANDLE="+test.flagHandle, + ) + cmd.Run() + got := cmd.ProcessState.ExitCode() + // ExitCode is either 0 or 1 on Plan 9. + if runtime.GOOS == "plan9" && test.expectExit != 0 { + test.expectExit = 1 + } + if got != test.expectExit { + t.Errorf("unexpected exit code for test case %+v \n: got %d, expect %d", + test, got, test.expectExit) + } + } +} + +func mustPanic(t *testing.T, testName string, expected string, f func()) { + t.Helper() + defer func() { + switch msg := recover().(type) { + case nil: + t.Errorf("%s\n: expected panic(%q), but did not panic", testName, expected) + case string: + if ok, _ := regexp.MatchString(expected, msg); !ok { + t.Errorf("%s\n: expected panic(%q), but got panic(%q)", testName, expected, msg) + } + default: + t.Errorf("%s\n: expected panic(%q), but got panic(%T%v)", testName, expected, msg, msg) + } + }() + f() +} + +func TestInvalidFlags(t *testing.T) { + tests := []struct { + flag string + errorMsg string + }{ + { + flag: "-foo", + errorMsg: "flag \"-foo\" begins with -", + }, + { + flag: "foo=bar", + errorMsg: "flag \"foo=bar\" contains =", + }, + } + + for _, test := range tests { + testName := fmt.Sprintf("FlagSet.Var(&v, %q, \"\")", test.flag) + + fs := NewFlagSet("", ContinueOnError) + buf := &strings.Builder{} + fs.SetOutput(buf) + + mustPanic(t, testName, test.errorMsg, func() { + var v flagVar + fs.Var(&v, test.flag, "") + }) + if msg := test.errorMsg + "\n"; msg != buf.String() { + t.Errorf("%s\n: unexpected output: expected %q, bug got %q", testName, msg, buf) + } + } +} + +func TestRedefinedFlags(t *testing.T) { + tests := []struct { + flagSetName string + errorMsg string + }{ + { + flagSetName: "", + errorMsg: "flag redefined: foo", + }, + { + flagSetName: "fs", + errorMsg: "fs flag redefined: foo", + }, + } + + for _, test := range tests { + testName := fmt.Sprintf("flag redefined in FlagSet(%q)", test.flagSetName) + + fs := NewFlagSet(test.flagSetName, ContinueOnError) + buf := &strings.Builder{} + fs.SetOutput(buf) + + var v flagVar + fs.Var(&v, "foo", "") + + mustPanic(t, testName, test.errorMsg, func() { + fs.Var(&v, "foo", "") + }) + if msg := test.errorMsg + "\n"; msg != buf.String() { + t.Errorf("%s\n: unexpected output: expected %q, bug got %q", testName, msg, buf) + } + } +} + +func TestUserDefinedBoolFunc(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + var ss []string + flags.BoolFunc("v", "usage", func(s string) error { + ss = append(ss, s) + return nil + }) + if err := flags.Parse([]string{"-v", "", "-v", "1", "-v=2"}); err != nil { + t.Error(err) + } + if len(ss) != 1 { + t.Fatalf("got %d args; want 1 arg", len(ss)) + } + want := "[true]" + if got := fmt.Sprint(ss); got != want { + t.Errorf("got %q; want %q", got, want) + } + // test usage + var buf strings.Builder + flags.SetOutput(&buf) + flags.Parse([]string{"-h"}) + if usage := buf.String(); !strings.Contains(usage, "usage") { + t.Errorf("usage string not included: %q", usage) + } + // test BoolFunc error + flags = NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + flags.BoolFunc("v", "usage", func(s string) error { + return fmt.Errorf("test error") + }) + // flag not set, so no error + if err := flags.Parse(nil); err != nil { + t.Error(err) + } + // flag set, expect error + if err := flags.Parse([]string{"-v", ""}); err == nil { + t.Error("got err == nil; want err != nil") + } else if errMsg := err.Error(); !strings.Contains(errMsg, "test error") { + t.Errorf(`got %q; error should contain "test error"`, errMsg) + } +} + +func TestDefineAfterSet(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + // Set by itself doesn't panic. + flags.Set("myFlag", "value") + + // Define-after-set panics. + mustPanic(t, "DefineAfterSet", "flag myFlag set at .*/flag_test.go:.* before being defined", func() { + _ = flags.String("myFlag", "default", "usage") + }) +} diff --git a/v2/conformance/flag_go1.26_test.go b/v2/conformance/flag_go1.26_test.go new file mode 100644 index 00000000..200f7245 --- /dev/null +++ b/v2/conformance/flag_go1.26_test.go @@ -0,0 +1,875 @@ +//go:build conformance && go1.26 && !go1.27 + +// Code generated by conformance/hack/sync.sh from the Go standard library's +// flag package test suite (src/flag/flag_test.go) at go1.26. DO NOT EDIT. +// +// This is upstream flag_test.go run against pflag v2 (dot-imported as "flag") to +// verify the drop-in-replacement promise. The build constraint pins it to the +// go1.26 toolchain so the CI matrix tests each supported Go release against +// that release's own flag tests. The only changes from upstream are the package +// clause, the dot-import target, the internal/testenv import, and a generated +// init() (just below the imports) that registers this copy with the guard in +// harness_test.go. Refresh with conformance/hack/sync.sh. + +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package conformance_test + +import ( + "bytes" + "fmt" + . "github.com/spf13/pflag/v2" + "github.com/spf13/pflag/v2/conformance/internal/testenv" + "io" + "os" + "os/exec" + "regexp" + "runtime" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +// Register this vendored copy with the guard in harness_test.go so an +// un-synced Go toolchain fails loudly instead of silently testing nothing. +func init() { vendoredVersion = "go1.26" } + +func boolString(s string) string { + if s == "0" { + return "false" + } + return "true" +} + +func TestEverything(t *testing.T) { + ResetForTesting(nil) + Bool("test_bool", false, "bool value") + Int("test_int", 0, "int value") + Int64("test_int64", 0, "int64 value") + Uint("test_uint", 0, "uint value") + Uint64("test_uint64", 0, "uint64 value") + String("test_string", "0", "string value") + Float64("test_float64", 0, "float64 value") + Duration("test_duration", 0, "time.Duration value") + Func("test_func", "func value", func(string) error { return nil }) + BoolFunc("test_boolfunc", "func", func(string) error { return nil }) + + m := make(map[string]*Flag) + desired := "0" + visitor := func(f *Flag) { + if len(f.Name) > 5 && f.Name[0:5] == "test_" { + m[f.Name] = f + ok := false + switch { + case f.Value.String() == desired: + ok = true + case f.Name == "test_bool" && f.Value.String() == boolString(desired): + ok = true + case f.Name == "test_duration" && f.Value.String() == desired+"s": + ok = true + case f.Name == "test_func" && f.Value.String() == "": + ok = true + case f.Name == "test_boolfunc" && f.Value.String() == "": + ok = true + } + if !ok { + t.Error("Visit: bad value", f.Value.String(), "for", f.Name) + } + } + } + VisitAll(visitor) + if len(m) != 10 { + t.Error("VisitAll misses some flags") + for k, v := range m { + t.Log(k, *v) + } + } + m = make(map[string]*Flag) + Visit(visitor) + if len(m) != 0 { + t.Errorf("Visit sees unset flags") + for k, v := range m { + t.Log(k, *v) + } + } + // Now set all flags + Set("test_bool", "true") + Set("test_int", "1") + Set("test_int64", "1") + Set("test_uint", "1") + Set("test_uint64", "1") + Set("test_string", "1") + Set("test_float64", "1") + Set("test_duration", "1s") + Set("test_func", "1") + Set("test_boolfunc", "") + desired = "1" + Visit(visitor) + if len(m) != 10 { + t.Error("Visit fails after set") + for k, v := range m { + t.Log(k, *v) + } + } + // Now test they're visited in sort order. + var flagNames []string + Visit(func(f *Flag) { flagNames = append(flagNames, f.Name) }) + if !slices.IsSorted(flagNames) { + t.Errorf("flag names not sorted: %v", flagNames) + } +} + +func TestGet(t *testing.T) { + ResetForTesting(nil) + Bool("test_bool", true, "bool value") + Int("test_int", 1, "int value") + Int64("test_int64", 2, "int64 value") + Uint("test_uint", 3, "uint value") + Uint64("test_uint64", 4, "uint64 value") + String("test_string", "5", "string value") + Float64("test_float64", 6, "float64 value") + Duration("test_duration", 7, "time.Duration value") + + visitor := func(f *Flag) { + if len(f.Name) > 5 && f.Name[0:5] == "test_" { + g, ok := f.Value.(Getter) + if !ok { + t.Errorf("Visit: value does not satisfy Getter: %T", f.Value) + return + } + switch f.Name { + case "test_bool": + ok = g.Get() == true + case "test_int": + ok = g.Get() == int(1) + case "test_int64": + ok = g.Get() == int64(2) + case "test_uint": + ok = g.Get() == uint(3) + case "test_uint64": + ok = g.Get() == uint64(4) + case "test_string": + ok = g.Get() == "5" + case "test_float64": + ok = g.Get() == float64(6) + case "test_duration": + ok = g.Get() == time.Duration(7) + } + if !ok { + t.Errorf("Visit: bad value %T(%v) for %s", g.Get(), g.Get(), f.Name) + } + } + } + VisitAll(visitor) +} + +func TestUsage(t *testing.T) { + called := false + ResetForTesting(func() { called = true }) + if CommandLine.Parse([]string{"-x"}) == nil { + t.Error("parse did not fail for unknown flag") + } + if !called { + t.Error("did not call Usage for unknown flag") + } +} + +func testParse(f *FlagSet, t *testing.T) { + if f.Parsed() { + t.Error("f.Parse() = true before Parse") + } + boolFlag := f.Bool("bool", false, "bool value") + bool2Flag := f.Bool("bool2", false, "bool2 value") + intFlag := f.Int("int", 0, "int value") + int64Flag := f.Int64("int64", 0, "int64 value") + uintFlag := f.Uint("uint", 0, "uint value") + uint64Flag := f.Uint64("uint64", 0, "uint64 value") + stringFlag := f.String("string", "0", "string value") + float64Flag := f.Float64("float64", 0, "float64 value") + durationFlag := f.Duration("duration", 5*time.Second, "time.Duration value") + extra := "one-extra-argument" + args := []string{ + "-bool", + "-bool2=true", + "--int", "22", + "--int64", "0x23", + "-uint", "24", + "--uint64", "25", + "-string", "hello", + "-float64", "2718e28", + "-duration", "2m", + extra, + } + if err := f.Parse(args); err != nil { + t.Fatal(err) + } + if !f.Parsed() { + t.Error("f.Parse() = false after Parse") + } + if *boolFlag != true { + t.Error("bool flag should be true, is ", *boolFlag) + } + if *bool2Flag != true { + t.Error("bool2 flag should be true, is ", *bool2Flag) + } + if *intFlag != 22 { + t.Error("int flag should be 22, is ", *intFlag) + } + if *int64Flag != 0x23 { + t.Error("int64 flag should be 0x23, is ", *int64Flag) + } + if *uintFlag != 24 { + t.Error("uint flag should be 24, is ", *uintFlag) + } + if *uint64Flag != 25 { + t.Error("uint64 flag should be 25, is ", *uint64Flag) + } + if *stringFlag != "hello" { + t.Error("string flag should be `hello`, is ", *stringFlag) + } + if *float64Flag != 2718e28 { + t.Error("float64 flag should be 2718e28, is ", *float64Flag) + } + if *durationFlag != 2*time.Minute { + t.Error("duration flag should be 2m, is ", *durationFlag) + } + if len(f.Args()) != 1 { + t.Error("expected one argument, got", len(f.Args())) + } else if f.Args()[0] != extra { + t.Errorf("expected argument %q got %q", extra, f.Args()[0]) + } +} + +func TestParse(t *testing.T) { + ResetForTesting(func() { t.Error("bad parse") }) + testParse(CommandLine, t) +} + +func TestFlagSetParse(t *testing.T) { + testParse(NewFlagSet("test", ContinueOnError), t) +} + +// Declare a user-defined flag type. +type flagVar []string + +func (f *flagVar) String() string { + return fmt.Sprint([]string(*f)) +} + +func (f *flagVar) Set(value string) error { + *f = append(*f, value) + return nil +} + +func TestUserDefined(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + flags.SetOutput(io.Discard) + var v flagVar + flags.Var(&v, "v", "usage") + if err := flags.Parse([]string{"-v", "1", "-v", "2", "-v=3"}); err != nil { + t.Error(err) + } + if len(v) != 3 { + t.Fatal("expected 3 args; got ", len(v)) + } + expect := "[1 2 3]" + if v.String() != expect { + t.Errorf("expected value %q got %q", expect, v.String()) + } +} + +func TestUserDefinedFunc(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + var ss []string + flags.Func("v", "usage", func(s string) error { + ss = append(ss, s) + return nil + }) + if err := flags.Parse([]string{"-v", "1", "-v", "2", "-v=3"}); err != nil { + t.Error(err) + } + if len(ss) != 3 { + t.Fatal("expected 3 args; got ", len(ss)) + } + expect := "[1 2 3]" + if got := fmt.Sprint(ss); got != expect { + t.Errorf("expected value %q got %q", expect, got) + } + // test usage + var buf strings.Builder + flags.SetOutput(&buf) + flags.Parse([]string{"-h"}) + if usage := buf.String(); !strings.Contains(usage, "usage") { + t.Errorf("usage string not included: %q", usage) + } + // test Func error + flags = NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + flags.Func("v", "usage", func(s string) error { + return fmt.Errorf("test error") + }) + // flag not set, so no error + if err := flags.Parse(nil); err != nil { + t.Error(err) + } + // flag set, expect error + if err := flags.Parse([]string{"-v", "1"}); err == nil { + t.Error("expected error; got none") + } else if errMsg := err.Error(); !strings.Contains(errMsg, "test error") { + t.Errorf(`error should contain "test error"; got %q`, errMsg) + } +} + +func TestUserDefinedForCommandLine(t *testing.T) { + const help = "HELP" + var result string + ResetForTesting(func() { result = help }) + Usage() + if result != help { + t.Fatalf("got %q; expected %q", result, help) + } +} + +// Declare a user-defined boolean flag type. +type boolFlagVar struct { + count int +} + +func (b *boolFlagVar) String() string { + return fmt.Sprintf("%d", b.count) +} + +func (b *boolFlagVar) Set(value string) error { + if value == "true" { + b.count++ + } + return nil +} + +func (b *boolFlagVar) IsBoolFlag() bool { + return b.count < 4 +} + +func TestUserDefinedBool(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + flags.SetOutput(io.Discard) + var b boolFlagVar + var err error + flags.Var(&b, "b", "usage") + if err = flags.Parse([]string{"-b", "-b", "-b", "-b=true", "-b=false", "-b", "barg", "-b"}); err != nil { + if b.count < 4 { + t.Error(err) + } + } + + if b.count != 4 { + t.Errorf("want: %d; got: %d", 4, b.count) + } + + if err == nil { + t.Error("expected error; got none") + } +} + +func TestUserDefinedBoolUsage(t *testing.T) { + var flags FlagSet + flags.Init("test", ContinueOnError) + var buf bytes.Buffer + flags.SetOutput(&buf) + var b boolFlagVar + flags.Var(&b, "b", "X") + b.count = 0 + // b.IsBoolFlag() will return true and usage will look boolean. + flags.PrintDefaults() + got := buf.String() + want := " -b\tX\n" + if got != want { + t.Errorf("false: want %q; got %q", want, got) + } + b.count = 4 + // b.IsBoolFlag() will return false and usage will look non-boolean. + flags.PrintDefaults() + got = buf.String() + want = " -b\tX\n -b value\n \tX\n" + if got != want { + t.Errorf("false: want %q; got %q", want, got) + } +} + +func TestSetOutput(t *testing.T) { + var flags FlagSet + var buf strings.Builder + flags.SetOutput(&buf) + flags.Init("test", ContinueOnError) + flags.Parse([]string{"-unknown"}) + if out := buf.String(); !strings.Contains(out, "-unknown") { + t.Logf("expected output mentioning unknown; got %q", out) + } +} + +// This tests that one can reset the flags. This still works but not well, and is +// superseded by FlagSet. +func TestChangingArgs(t *testing.T) { + ResetForTesting(func() { t.Fatal("bad parse") }) + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"cmd", "-before", "subcmd", "-after", "args"} + before := Bool("before", false, "") + if err := CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + cmd := Arg(0) + os.Args = Args() + after := Bool("after", false, "") + Parse() + args := Args() + + if !*before || cmd != "subcmd" || !*after || len(args) != 1 || args[0] != "args" { + t.Fatalf("expected true subcmd true [args] got %v %v %v %v", *before, cmd, *after, args) + } +} + +// Test that -help invokes the usage message and returns ErrHelp. +func TestHelp(t *testing.T) { + var helpCalled = false + fs := NewFlagSet("help test", ContinueOnError) + fs.Usage = func() { helpCalled = true } + var flag bool + fs.BoolVar(&flag, "flag", false, "regular flag") + // Regular flag invocation should work + err := fs.Parse([]string{"-flag=true"}) + if err != nil { + t.Fatal("expected no error; got ", err) + } + if !flag { + t.Error("flag was not set by -flag") + } + if helpCalled { + t.Error("help called for regular flag") + helpCalled = false // reset for next test + } + // Help flag should work as expected. + err = fs.Parse([]string{"-help"}) + if err == nil { + t.Fatal("error expected") + } + if err != ErrHelp { + t.Fatal("expected ErrHelp; got ", err) + } + if !helpCalled { + t.Fatal("help was not called") + } + // If we define a help flag, that should override. + var help bool + fs.BoolVar(&help, "help", false, "help flag") + helpCalled = false + err = fs.Parse([]string{"-help"}) + if err != nil { + t.Fatal("expected no error for defined -help; got ", err) + } + if helpCalled { + t.Fatal("help was called; should not have been for defined help flag") + } +} + +// zeroPanicker is a flag.Value whose String method panics if its dontPanic +// field is false. +type zeroPanicker struct { + dontPanic bool + v string +} + +func (f *zeroPanicker) Set(s string) error { + f.v = s + return nil +} + +func (f *zeroPanicker) String() string { + if !f.dontPanic { + panic("panic!") + } + return f.v +} + +const defaultOutput = ` -A for bootstrapping, allow 'any' type + -Alongflagname + disable bounds checking + -C a boolean defaulting to true (default true) + -D path + set relative path for local imports + -E string + issue 23543 (default "0") + -F number + a non-zero number (default 2.7) + -G float + a float that defaults to zero + -M string + a multiline + help + string + -N int + a non-zero int (default 27) + -O a flag + multiline help string (default true) + -V list + a list of strings (default [a b]) + -Z int + an int that defaults to zero + -ZP0 value + a flag whose String method panics when it is zero + -ZP1 value + a flag whose String method panics when it is zero + -maxT timeout + set timeout for dial + +panic calling String method on zero flag_test.zeroPanicker for flag ZP0: panic! +panic calling String method on zero flag_test.zeroPanicker for flag ZP1: panic! +` + +func TestPrintDefaults(t *testing.T) { + fs := NewFlagSet("print defaults test", ContinueOnError) + var buf strings.Builder + fs.SetOutput(&buf) + fs.Bool("A", false, "for bootstrapping, allow 'any' type") + fs.Bool("Alongflagname", false, "disable bounds checking") + fs.Bool("C", true, "a boolean defaulting to true") + fs.String("D", "", "set relative `path` for local imports") + fs.String("E", "0", "issue 23543") + fs.Float64("F", 2.7, "a non-zero `number`") + fs.Float64("G", 0, "a float that defaults to zero") + fs.String("M", "", "a multiline\nhelp\nstring") + fs.Int("N", 27, "a non-zero int") + fs.Bool("O", true, "a flag\nmultiline help string") + fs.Var(&flagVar{"a", "b"}, "V", "a `list` of strings") + fs.Int("Z", 0, "an int that defaults to zero") + fs.Var(&zeroPanicker{true, ""}, "ZP0", "a flag whose String method panics when it is zero") + fs.Var(&zeroPanicker{true, "something"}, "ZP1", "a flag whose String method panics when it is zero") + fs.Duration("maxT", 0, "set `timeout` for dial") + fs.PrintDefaults() + got := buf.String() + if got != defaultOutput { + t.Errorf("got:\n%q\nwant:\n%q", got, defaultOutput) + } +} + +// Issue 19230: validate range of Int and Uint flag values. +func TestIntFlagOverflow(t *testing.T) { + if strconv.IntSize != 32 { + return + } + ResetForTesting(nil) + Int("i", 0, "") + Uint("u", 0, "") + if err := Set("i", "2147483648"); err == nil { + t.Error("unexpected success setting Int") + } + if err := Set("u", "4294967296"); err == nil { + t.Error("unexpected success setting Uint") + } +} + +// Issue 20998: Usage should respect CommandLine.output. +func TestUsageOutput(t *testing.T) { + ResetForTesting(DefaultUsage) + var buf strings.Builder + CommandLine.SetOutput(&buf) + defer func(old []string) { os.Args = old }(os.Args) + os.Args = []string{"app", "-i=1", "-unknown"} + Parse() + const want = "flag provided but not defined: -i\nUsage of app:\n" + if got := buf.String(); got != want { + t.Errorf("output = %q; want %q", got, want) + } +} + +func TestGetters(t *testing.T) { + expectedName := "flag set" + expectedErrorHandling := ContinueOnError + expectedOutput := io.Writer(os.Stderr) + fs := NewFlagSet(expectedName, expectedErrorHandling) + + if fs.Name() != expectedName { + t.Errorf("unexpected name: got %s, expected %s", fs.Name(), expectedName) + } + if fs.ErrorHandling() != expectedErrorHandling { + t.Errorf("unexpected ErrorHandling: got %d, expected %d", fs.ErrorHandling(), expectedErrorHandling) + } + if fs.Output() != expectedOutput { + t.Errorf("unexpected output: got %#v, expected %#v", fs.Output(), expectedOutput) + } + + expectedName = "gopher" + expectedErrorHandling = ExitOnError + expectedOutput = os.Stdout + fs.Init(expectedName, expectedErrorHandling) + fs.SetOutput(expectedOutput) + + if fs.Name() != expectedName { + t.Errorf("unexpected name: got %s, expected %s", fs.Name(), expectedName) + } + if fs.ErrorHandling() != expectedErrorHandling { + t.Errorf("unexpected ErrorHandling: got %d, expected %d", fs.ErrorHandling(), expectedErrorHandling) + } + if fs.Output() != expectedOutput { + t.Errorf("unexpected output: got %v, expected %v", fs.Output(), expectedOutput) + } +} + +func TestParseError(t *testing.T) { + for _, typ := range []string{"bool", "int", "int64", "uint", "uint64", "float64", "duration"} { + fs := NewFlagSet("parse error test", ContinueOnError) + fs.SetOutput(io.Discard) + _ = fs.Bool("bool", false, "") + _ = fs.Int("int", 0, "") + _ = fs.Int64("int64", 0, "") + _ = fs.Uint("uint", 0, "") + _ = fs.Uint64("uint64", 0, "") + _ = fs.Float64("float64", 0, "") + _ = fs.Duration("duration", 0, "") + // Strings cannot give errors. + args := []string{"-" + typ + "=x"} + err := fs.Parse(args) // x is not a valid setting for any flag. + if err == nil { + t.Errorf("Parse(%q)=%v; expected parse error", args, err) + continue + } + if !strings.Contains(err.Error(), "invalid") || !strings.Contains(err.Error(), "parse error") { + t.Errorf("Parse(%q)=%v; expected parse error", args, err) + } + } +} + +func TestRangeError(t *testing.T) { + bad := []string{ + "-int=123456789012345678901", + "-int64=123456789012345678901", + "-uint=123456789012345678901", + "-uint64=123456789012345678901", + "-float64=1e1000", + } + for _, arg := range bad { + fs := NewFlagSet("parse error test", ContinueOnError) + fs.SetOutput(io.Discard) + _ = fs.Int("int", 0, "") + _ = fs.Int64("int64", 0, "") + _ = fs.Uint("uint", 0, "") + _ = fs.Uint64("uint64", 0, "") + _ = fs.Float64("float64", 0, "") + // Strings cannot give errors, and bools and durations do not return strconv.NumError. + err := fs.Parse([]string{arg}) + if err == nil { + t.Errorf("Parse(%q)=%v; expected range error", arg, err) + continue + } + if !strings.Contains(err.Error(), "invalid") || !strings.Contains(err.Error(), "value out of range") { + t.Errorf("Parse(%q)=%v; expected range error", arg, err) + } + } +} + +func TestExitCode(t *testing.T) { + testenv.MustHaveExec(t) + + magic := 123 + if os.Getenv("GO_CHILD_FLAG") != "" { + fs := NewFlagSet("test", ExitOnError) + if os.Getenv("GO_CHILD_FLAG_HANDLE") != "" { + var b bool + fs.BoolVar(&b, os.Getenv("GO_CHILD_FLAG_HANDLE"), false, "") + } + fs.Parse([]string{os.Getenv("GO_CHILD_FLAG")}) + os.Exit(magic) + } + + tests := []struct { + flag string + flagHandle string + expectExit int + }{ + { + flag: "-h", + expectExit: 0, + }, + { + flag: "-help", + expectExit: 0, + }, + { + flag: "-undefined", + expectExit: 2, + }, + { + flag: "-h", + flagHandle: "h", + expectExit: magic, + }, + { + flag: "-help", + flagHandle: "help", + expectExit: magic, + }, + } + + for _, test := range tests { + cmd := exec.Command(testenv.Executable(t), "-test.run=^TestExitCode$") + cmd.Env = append( + os.Environ(), + "GO_CHILD_FLAG="+test.flag, + "GO_CHILD_FLAG_HANDLE="+test.flagHandle, + ) + cmd.Run() + got := cmd.ProcessState.ExitCode() + // ExitCode is either 0 or 1 on Plan 9. + if runtime.GOOS == "plan9" && test.expectExit != 0 { + test.expectExit = 1 + } + if got != test.expectExit { + t.Errorf("unexpected exit code for test case %+v \n: got %d, expect %d", + test, got, test.expectExit) + } + } +} + +func mustPanic(t *testing.T, testName string, expected string, f func()) { + t.Helper() + defer func() { + switch msg := recover().(type) { + case nil: + t.Errorf("%s\n: expected panic(%q), but did not panic", testName, expected) + case string: + if ok, _ := regexp.MatchString(expected, msg); !ok { + t.Errorf("%s\n: expected panic(%q), but got panic(%q)", testName, expected, msg) + } + default: + t.Errorf("%s\n: expected panic(%q), but got panic(%T%v)", testName, expected, msg, msg) + } + }() + f() +} + +func TestInvalidFlags(t *testing.T) { + tests := []struct { + flag string + errorMsg string + }{ + { + flag: "-foo", + errorMsg: "flag \"-foo\" begins with -", + }, + { + flag: "foo=bar", + errorMsg: "flag \"foo=bar\" contains =", + }, + } + + for _, test := range tests { + testName := fmt.Sprintf("FlagSet.Var(&v, %q, \"\")", test.flag) + + fs := NewFlagSet("", ContinueOnError) + buf := &strings.Builder{} + fs.SetOutput(buf) + + mustPanic(t, testName, test.errorMsg, func() { + var v flagVar + fs.Var(&v, test.flag, "") + }) + if msg := test.errorMsg + "\n"; msg != buf.String() { + t.Errorf("%s\n: unexpected output: expected %q, bug got %q", testName, msg, buf) + } + } +} + +func TestRedefinedFlags(t *testing.T) { + tests := []struct { + flagSetName string + errorMsg string + }{ + { + flagSetName: "", + errorMsg: "flag redefined: foo", + }, + { + flagSetName: "fs", + errorMsg: "fs flag redefined: foo", + }, + } + + for _, test := range tests { + testName := fmt.Sprintf("flag redefined in FlagSet(%q)", test.flagSetName) + + fs := NewFlagSet(test.flagSetName, ContinueOnError) + buf := &strings.Builder{} + fs.SetOutput(buf) + + var v flagVar + fs.Var(&v, "foo", "") + + mustPanic(t, testName, test.errorMsg, func() { + fs.Var(&v, "foo", "") + }) + if msg := test.errorMsg + "\n"; msg != buf.String() { + t.Errorf("%s\n: unexpected output: expected %q, bug got %q", testName, msg, buf) + } + } +} + +func TestUserDefinedBoolFunc(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + var ss []string + flags.BoolFunc("v", "usage", func(s string) error { + ss = append(ss, s) + return nil + }) + if err := flags.Parse([]string{"-v", "", "-v", "1", "-v=2"}); err != nil { + t.Error(err) + } + if len(ss) != 1 { + t.Fatalf("got %d args; want 1 arg", len(ss)) + } + want := "[true]" + if got := fmt.Sprint(ss); got != want { + t.Errorf("got %q; want %q", got, want) + } + // test usage + var buf strings.Builder + flags.SetOutput(&buf) + flags.Parse([]string{"-h"}) + if usage := buf.String(); !strings.Contains(usage, "usage") { + t.Errorf("usage string not included: %q", usage) + } + // test BoolFunc error + flags = NewFlagSet("test", ContinueOnError) + flags.SetOutput(io.Discard) + flags.BoolFunc("v", "usage", func(s string) error { + return fmt.Errorf("test error") + }) + // flag not set, so no error + if err := flags.Parse(nil); err != nil { + t.Error(err) + } + // flag set, expect error + if err := flags.Parse([]string{"-v", ""}); err == nil { + t.Error("got err == nil; want err != nil") + } else if errMsg := err.Error(); !strings.Contains(errMsg, "test error") { + t.Errorf(`got %q; error should contain "test error"`, errMsg) + } +} + +func TestDefineAfterSet(t *testing.T) { + flags := NewFlagSet("test", ContinueOnError) + // Set by itself doesn't panic. + flags.Set("myFlag", "value") + + // Define-after-set panics. + mustPanic(t, "DefineAfterSet", "flag myFlag set at .*/flag_test.go:.* before being defined", func() { + _ = flags.String("myFlag", "default", "usage") + }) +} diff --git a/v2/conformance/gnuposix_test.go b/v2/conformance/gnuposix_test.go new file mode 100644 index 00000000..74deba78 --- /dev/null +++ b/v2/conformance/gnuposix_test.go @@ -0,0 +1,249 @@ +//go:build conformance + +package conformance_test + +// POSIX/GNU command-line argument syntax conformance. +// +// Besides being a drop-in for the stdlib flag package, pflag's README promises +// compatibility with "the GNU extensions to the POSIX recommendations for +// command-line options". The two authoritative sources are: +// +// - POSIX "Utility Syntax Guidelines" 1-14 (the base rules): +// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html +// - GNU "Argument Syntax" (the long-option extension layer): +// https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html +// +// POSIX is authoritative: where this spec conflicts with the stdlib-flag drop-in +// spec (flag_go*_test.go), POSIX wins and the conflict is recorded explicitly in +// divergences.json (enforced by the oracle in hack/oracle). There is no upstream +// test to vendor (these rules are +// prose), so each case below is hand-written and cites the Guideline (Gn) or GNU +// rule it checks. Like the rest of the package it builds only under the +// "conformance" tag and is expected to fail until v2 implements the native flag +// API. + +import ( + "io" + "slices" + "testing" + + . "github.com/spf13/pflag/v2" +) + +func newGNUFlagSet() *FlagSet { + fs := NewFlagSet("gnuposix", ContinueOnError) + fs.SetOutput(io.Discard) + return fs +} + +func gnuParse(t *testing.T, fs *FlagSet, args ...string) { + t.Helper() + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse(%q) returned unexpected error: %v", args, err) + } +} + +func wantRemainingArgs(t *testing.T, fs *FlagSet, want ...string) { + t.Helper() + if got := fs.Args(); !slices.Equal(got, want) { + t.Errorf("non-option args = %q, want %q", got, want) + } +} + +// TestShortOptions covers the POSIX short-option guidelines. +func TestShortOptions(t *testing.T) { + // G4: "All options should be preceded by the '-' delimiter character." + // G3: "Each option name should be a single alphanumeric character." + t.Run("single short option", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "-v") + if !*v { + t.Errorf("-v: verbose = false, want true") + } + wantRemainingArgs(t, fs) + }) + + // G5: "One or more options without option-arguments ... should be accepted + // when grouped behind one '-' delimiter." + t.Run("grouped short options", func(t *testing.T) { + fs := newGNUFlagSet() + a := fs.BoolP("all", "a", false, "") + b := fs.BoolP("brief", "b", false, "") + c := fs.BoolP("count", "c", false, "") + gnuParse(t, fs, "-abc") + if !*a || !*b || !*c { + t.Errorf("-abc: got a=%v b=%v c=%v, want all true", *a, *b, *c) + } + }) + + // G6: "Each option and option-argument should be a separate argument", + // except (Utility Argument Syntax item 2) an option-argument may be in the + // same token as its option. + t.Run("short option argument as separate token", func(t *testing.T) { + fs := newGNUFlagSet() + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "-o", "file.txt") + if *o != "file.txt" { + t.Errorf("-o file.txt: output = %q, want %q", *o, "file.txt") + } + }) + t.Run("short option argument in same token", func(t *testing.T) { + fs := newGNUFlagSet() + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "-ofile.txt") + if *o != "file.txt" { + t.Errorf("-ofile.txt: output = %q, want %q", *o, "file.txt") + } + }) + + // G5: "... followed by at most one option that takes an option-argument, + // should be accepted when grouped behind one '-' delimiter." + t.Run("cluster ending in an option that takes an argument", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "-vofile.txt") + if !*v || *o != "file.txt" { + t.Errorf("-vofile.txt: got verbose=%v output=%q, want true and %q", *v, *o, "file.txt") + } + }) +} + +// TestLongOptions covers the GNU long-option extension (not part of base POSIX). +func TestLongOptions(t *testing.T) { + // GNU: long options consist of "--" followed by alphanumeric characters and + // dashes. + t.Run("long option", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "--verbose") + if !*v { + t.Errorf("--verbose: verbose = false, want true") + } + }) + t.Run("long option name may contain dashes", func(t *testing.T) { + fs := newGNUFlagSet() + n := fs.BoolP("dry-run", "n", false, "") + gnuParse(t, fs, "--dry-run") + if !*n { + t.Errorf("--dry-run: dry-run = false, want true") + } + }) + + // GNU: "To specify an argument for a long option, write --name=value." + t.Run("long option argument with =", func(t *testing.T) { + fs := newGNUFlagSet() + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "--output=file.txt") + if *o != "file.txt" { + t.Errorf("--output=file.txt: output = %q, want %q", *o, "file.txt") + } + }) + // GNU getopt_long also accepts the argument as the following token. + t.Run("long option argument as separate token", func(t *testing.T) { + fs := newGNUFlagSet() + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "--output", "file.txt") + if *o != "file.txt" { + t.Errorf("--output file.txt: output = %q, want %q", *o, "file.txt") + } + }) +} + +// TestOptionTerminatorAndDash covers the two special tokens. +func TestOptionTerminatorAndDash(t *testing.T) { + // G10: "The first '--' argument that is not an option-argument should be + // accepted as a delimiter indicating the end of options." Anything after is + // an operand, even if it begins with '-'. + t.Run("double dash terminates option processing", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "-v", "--", "-x", "file") + if !*v { + t.Errorf("-v before --: verbose = false, want true") + } + wantRemainingArgs(t, fs, "-x", "file") + }) + + // G13: a single '-' operand denotes standard input/output, i.e. it is an + // ordinary operand, not an option. + t.Run("single hyphen is an operand", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "-v", "-", "file") + if !*v { + t.Errorf("-v: verbose = false, want true") + } + wantRemainingArgs(t, fs, "-", "file") + }) +} + +// TestOptionOrdering covers operand ordering and option repetition. +func TestOptionOrdering(t *testing.T) { + // GNU extension to G9 ("All options should precede operands"): GNU + // implementations reorder argv so options and operands may be interspersed. + // pflag uses this GNU behavior by default. + t.Run("options interspersed with operands (GNU default)", func(t *testing.T) { + fs := newGNUFlagSet() + v := fs.BoolP("verbose", "v", false, "") + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "src1", "-v", "src2", "-o", "out", "src3") + if !*v || *o != "out" { + t.Errorf("interspersed: got verbose=%v output=%q, want true and %q", *v, *o, "out") + } + wantRemainingArgs(t, fs, "src1", "src2", "src3") + }) + + // G9 (strict POSIX / POSIXLY_CORRECT): option processing stops at the first + // operand. pflag opts into this via SetInterspersed(false). + t.Run("strict POSIX ordering stops at first operand", func(t *testing.T) { + fs := newGNUFlagSet() + fs.SetInterspersed(false) + v := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "src1", "-v") + if *v { + t.Errorf("interspersing disabled: -v after an operand should not be parsed") + } + wantRemainingArgs(t, fs, "src1", "-v") + }) + + // G11: "The order of different options relative to one another should not + // matter." Options may also appear multiple times. + t.Run("an option may appear multiple times", func(t *testing.T) { + fs := newGNUFlagSet() + n := fs.CountP("verbose", "v", "") + gnuParse(t, fs, "-v", "-v", "-vv") + if *n != 4 { + t.Errorf("repeated -v: count = %d, want 4", *n) + } + }) + t.Run("options may be supplied in any order", func(t *testing.T) { + fs := newGNUFlagSet() + a := fs.BoolP("all", "a", false, "") + o := fs.StringP("output", "o", "", "") + gnuParse(t, fs, "-o", "out", "-a") + if !*a || *o != "out" { + t.Errorf("got all=%v output=%q, want true and %q", *a, *o, "out") + } + }) +} + +// TestLongOptionAbbreviation encodes the one GNU rule pflag has historically +// chosen NOT to implement: +// +// "the user can abbreviate the option name as long as the abbreviation is +// unique." +// +// This is a pflag-vs-GNU divergence (not a stdlib conflict), recorded in +// divergences.json. It is kept here as the spec: when v2 reaches this point, +// decide deliberately — implement unambiguous abbreviation, or change this to +// t.Skip documenting the divergence. Do not just delete it. +func TestLongOptionAbbreviation(t *testing.T) { + fs := newGNUFlagSet() + verbose := fs.BoolP("verbose", "v", false, "") + gnuParse(t, fs, "--verb") + if !*verbose { + t.Errorf("--verb: should be accepted as an unambiguous abbreviation of --verbose") + } +} diff --git a/v2/conformance/hack/oracle/main.go b/v2/conformance/hack/oracle/main.go new file mode 100644 index 00000000..3a0a80b0 --- /dev/null +++ b/v2/conformance/hack/oracle/main.go @@ -0,0 +1,207 @@ +// Command oracle reads `go test -json` output for the conformance suites on +// stdin and decides whether pflag v2 conforms, treating the documented +// divergences (conformance/divergences.json) as expected outcomes. +// +// When the catalogue status is "enforcing", it passes (exit 0) iff the suite +// built and ran, and: +// +// - every failing top-level test is documented (a failure that is NOT in the +// catalogue is a regression -> fail); +// - every PERMANENT divergence test actually failed/skipped (one that passed is +// a resolved divergence to retire -> fail; one that never ran names an +// unknown test -> fail); +// - "not-implemented-yet" tests are tolerated however they end up; if one now +// passes the oracle nudges you to retire it but stays green. +// +// When the status is "bootstrapping", a build failure is tolerated (green) so CI +// can pass while v2 has no API yet. +// +// Usage: +// +// go test -tags conformance -json ./conformance | go run ./conformance/hack/oracle +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/spf13/pflag/v2/conformance" + "github.com/spf13/pflag/v2/conformance/internal/divergence" +) + +// event is the subset of `go test -json` records we care about. +type event struct { + Action string `json:"Action"` + Test string `json:"Test"` +} + +func main() { + m, err := conformance.Manifest() + if err != nil { + fmt.Fprintln(os.Stderr, "oracle: loading divergence catalogue:", err) + os.Exit(2) + } + if err := m.Validate(); err != nil { + fmt.Fprintln(os.Stderr, "oracle: invalid divergence catalogue:", err) + os.Exit(2) + } + events, err := readEvents(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "oracle: reading go test -json:", err) + os.Exit(2) + } + rep := evaluate(events, m.Expected(), m.BuildRequired()) + fmt.Print(rep.String()) + if !rep.OK { + os.Exit(1) + } +} + +func readEvents(r io.Reader) ([]event, error) { + var evs []event + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 || line[0] != '{' { + continue + } + var e event + if err := json.Unmarshal(line, &e); err != nil { + continue // non-event output line + } + evs = append(evs, e) + } + return evs, sc.Err() +} + +type report struct { + OK bool + Built bool + BuildRequired bool + Ran int + + Regressions []string // failed but not documented + Resolved []string // permanent divergence that passed + UnknownExpected []string // permanent divergence that never ran + Retirable []string // not-implemented-yet test that now passes (soft) + Handled []string // documented and failed/skipped (the good case) +} + +// evaluate compares observed test outcomes against the catalogue. expected maps a +// top-level test name to its category; buildRequired reports whether a build +// failure should be red. +func evaluate(events []event, expected map[string]divergence.Category, buildRequired bool) report { + const passAction, failAction, skipAction = "pass", "fail", "skip" + + outcome := make(map[string]string) // top-level test name -> last terminal action + for _, e := range events { + if e.Test == "" || strings.Contains(e.Test, "/") { + continue // package-level event or subtest; track top-level only + } + switch e.Action { + case passAction, failAction, skipAction: + outcome[e.Test] = e.Action + } + } + + var rep report + rep.Ran = len(outcome) + rep.Built = rep.Ran > 0 + rep.BuildRequired = buildRequired + + if !rep.Built { + rep.OK = !buildRequired + return rep + } + + for name, act := range outcome { + cat, isExpected := expected[name] + permanent := isExpected && cat.Permanent() + switch { + case act == failAction && !isExpected: + rep.Regressions = append(rep.Regressions, name) + case act == passAction && permanent: + rep.Resolved = append(rep.Resolved, name) + case act == passAction && isExpected: // not-implemented-yet that now passes + rep.Retirable = append(rep.Retirable, name) + case isExpected && (act == failAction || act == skipAction): + rep.Handled = append(rep.Handled, name) + } + } + // A permanent divergence that never ran means the catalogue names an unknown + // test. (not-implemented-yet entries are allowed to never run.) + for name, cat := range expected { + if !cat.Permanent() { + continue + } + if _, ran := outcome[name]; !ran { + rep.UnknownExpected = append(rep.UnknownExpected, name) + } + } + + sort.Strings(rep.Regressions) + sort.Strings(rep.Resolved) + sort.Strings(rep.UnknownExpected) + sort.Strings(rep.Retirable) + sort.Strings(rep.Handled) + + rep.OK = len(rep.Regressions) == 0 && + len(rep.Resolved) == 0 && + len(rep.UnknownExpected) == 0 + + return rep +} + +func (r report) String() string { + var b strings.Builder + b.WriteString("== conformance oracle ==\n") + + if !r.Built { + if r.BuildRequired { + b.WriteString("FAIL: the conformance suite did not build or ran no tests.\n") + } else { + b.WriteString("OK (bootstrapping): the suite does not build yet; conformance not enforced.\n") + b.WriteString(" Flip divergences.json status to \"enforcing\" once v2 compiles.\n") + } + return b.String() + } + + fmt.Fprintf(&b, "ran %d top-level tests\n", r.Ran) + fmt.Fprintf(&b, "documented outcomes handled (failed/skipped as expected): %d\n", len(r.Handled)) + + writeList := func(title string, names []string, hint string) { + if len(names) == 0 { + return + } + fmt.Fprintf(&b, "\n%s:\n", title) + for _, n := range names { + fmt.Fprintf(&b, " - %s\n", n) + } + if hint != "" { + fmt.Fprintf(&b, " -> %s\n", hint) + } + } + writeList("REGRESSIONS (undocumented failures)", r.Regressions, + "fix the code, or add it to divergences.json (category not-implemented-yet while building out)") + writeList("RESOLVED DIVERGENCES (permanent divergence now passes)", r.Resolved, + "remove it from divergences.json") + writeList("UNKNOWN EXPECTED (catalogue names a test that never ran)", r.UnknownExpected, + "fix the test name in divergences.json") + // Retirable is informational only; it does not fail the run. + writeList("RETIRABLE (not-implemented-yet test now passes)", r.Retirable, + "implemented — remove it from divergences.json") + + b.WriteString("\n") + if r.OK { + b.WriteString("PASS: outcomes match the documented catalogue.\n") + } else { + b.WriteString("FAIL: see above.\n") + } + return b.String() +} diff --git a/v2/conformance/hack/oracle/main_test.go b/v2/conformance/hack/oracle/main_test.go new file mode 100644 index 00000000..894de3b5 --- /dev/null +++ b/v2/conformance/hack/oracle/main_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "testing" + + "github.com/spf13/pflag/v2/conformance/internal/divergence" +) + +func ev(action, test string) event { return event{Action: action, Test: test} } + +func TestEvaluate(t *testing.T) { + // Documented catalogue used by every case: TestParse is a permanent + // divergence, TestNewThing is a temporary not-implemented-yet entry. + expected := map[string]divergence.Category{ + "TestParse": divergence.POSIXOverridesStdlib, + "TestNewThing": divergence.NotImplementedYet, + } + + tests := []struct { + name string + events []event + buildRequired bool + wantOK bool + wantRegressions []string + wantResolved []string + wantUnknown []string + wantRetirable []string + }{ + { + name: "build failure while bootstrapping is tolerated", + events: []event{{Action: "fail"}}, + buildRequired: false, + wantOK: true, + }, + { + name: "build failure while enforcing is red", + events: []event{{Action: "fail"}}, + buildRequired: true, + wantOK: false, + }, + { + name: "perfect: permanent fails, not-implemented fails, others pass", + events: []event{ + ev("fail", "TestParse"), + ev("fail", "TestNewThing"), + ev("pass", "TestShortOptions"), + }, + buildRequired: true, + wantOK: true, + }, + { + name: "not-implemented test that passes is retirable but still green", + events: []event{ + ev("fail", "TestParse"), + ev("pass", "TestNewThing"), + ev("pass", "TestShortOptions"), + }, + buildRequired: true, + wantOK: true, + wantRetirable: []string{"TestNewThing"}, + }, + { + name: "not-implemented test that never ran is fine", + events: []event{ + ev("fail", "TestParse"), + ev("pass", "TestShortOptions"), + }, + buildRequired: true, + wantOK: true, + }, + { + name: "regression: undocumented failure", + events: []event{ + ev("fail", "TestParse"), + ev("fail", "TestShortOptions"), + }, + buildRequired: true, + wantOK: false, + wantRegressions: []string{"TestShortOptions"}, + }, + { + name: "resolved permanent divergence is red", + events: []event{ + ev("pass", "TestParse"), + }, + buildRequired: true, + wantOK: false, + wantResolved: []string{"TestParse"}, + }, + { + name: "permanent divergence that never ran is red", + events: []event{ + ev("pass", "TestShortOptions"), + }, + buildRequired: true, + wantOK: false, + wantUnknown: []string{"TestParse"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evaluate(tc.events, expected, tc.buildRequired) + if got.OK != tc.wantOK { + t.Errorf("OK = %v, want %v\nreport:\n%s", got.OK, tc.wantOK, got.String()) + } + if !equalStrings(got.Regressions, tc.wantRegressions) { + t.Errorf("Regressions = %v, want %v", got.Regressions, tc.wantRegressions) + } + if !equalStrings(got.Resolved, tc.wantResolved) { + t.Errorf("Resolved = %v, want %v", got.Resolved, tc.wantResolved) + } + if !equalStrings(got.UnknownExpected, tc.wantUnknown) { + t.Errorf("UnknownExpected = %v, want %v", got.UnknownExpected, tc.wantUnknown) + } + if !equalStrings(got.Retirable, tc.wantRetirable) { + t.Errorf("Retirable = %v, want %v", got.Retirable, tc.wantRetirable) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/v2/conformance/hack/sync.sh b/v2/conformance/hack/sync.sh new file mode 100755 index 00000000..169c9b37 --- /dev/null +++ b/v2/conformance/hack/sync.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# +# sync.sh vendors the standard library's own flag package test suite +# (src/flag/flag_test.go) so it can be run against pflag v2 as a drop-in +# compatibility ("conformance") check. See ../README.md for the why. +# +# A separate copy is vendored per Go minor version, each guarded by a build +# constraint (e.g. `go1.26 && !go1.27`) so the toolchain running the tests +# builds exactly the copy matching its version. That makes upstream API or +# behavioral changes between Go releases show up as diffs in this directory and +# as separate pass/fail signals in the CI matrix. +# +# Usage: +# ./sync.sh # re-sync every already-vendored version +# ./sync.sh 1.25 1.26 # add/refresh copies for the named minor versions +# +# This script only adds or refreshes; it never deletes. To drop a version, +# remove its vendored file: +# rm flag_go1.25_test.go +# +# For a requested version that matches the local toolchain, the local GOROOT +# source is used. Otherwise the file is fetched from the corresponding Go +# release branch on GitHub (requires network access). +# +# The only edits applied to the upstream file are mechanical and unavoidable: +# - package clause: package flag_test -> package conformance +# - package under test: . "flag" -> . "github.com/spf13/pflag/v2" (kept a dot import) +# - internal/testenv: -> the local shim (not importable outside the Go tree) +# - prepend build tags + a generated banner +# +# Helpers the stdlib keeps in its internal export_test.go (DefaultUsage, +# ResetForTesting) are reimplemented against v2's public API in ../harness_test.go. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +out_dir="$(dirname "$script_dir")" # the conformance/ directory + +# local toolchain minor version, e.g. "1.26" from "go1.26.4-X:nodwarf5" +local_goversion="$(go env GOVERSION)" +local_minor="$(printf '%s' "$local_goversion" | grep -oE 'go1\.[0-9]+' | head -1 | sed 's/^go//')" + +# Determine which versions to vendor. With explicit args, vendor exactly those +# (use this to add or drop a version). With no args, re-sync every version +# already vendored here (discovered from the flag_go*_test.go filenames) so +# `go generate` refreshes the whole set; fall back to the local toolchain +# version when nothing is vendored yet. +if [[ $# -gt 0 ]]; then + versions=("$@") +else + versions=() + for f in "$out_dir"/flag_go*_test.go; do + [[ -e "$f" ]] || continue + v="$(basename "$f")" + v="${v#flag_go}" + v="${v%_test.go}" + versions+=("$v") + done + if [[ ${#versions[@]} -eq 0 ]]; then + versions=("$local_minor") + fi +fi + +fetch_source() { # $1=minor (e.g. 1.25) -> prints flag_test.go to stdout + local minor="$1" + if [[ "$minor" == "$local_minor" ]]; then + cat "$(go env GOROOT)/src/flag/flag_test.go" + else + local url="https://raw.githubusercontent.com/golang/go/release-branch.go${minor}/src/flag/flag_test.go" + echo " fetching $url" >&2 + curl -fsSL --max-time 30 "$url" + fi +} + +for minor in "${versions[@]}"; do + major="${minor%%.*}" # 1 + min="${minor##*.}" # 26 + next="${major}.$((min + 1))" # 1.27 + tag="conformance && go${minor} && !go${next}" + + test_file="$out_dir/flag_go${minor}_test.go" + + echo "vendoring go${minor} -> $(basename "$test_file")" + + { + cat <"$test_file" + + gofmt -w "$test_file" +done + +echo +echo "done. vendored: ${versions[*]}" +echo "run with: go test -tags conformance ./conformance/..." +echo "note: expected to fail until v2 implements the flag API (see conformance/README.md)." diff --git a/v2/conformance/harness_test.go b/v2/conformance/harness_test.go new file mode 100644 index 00000000..0a961000 --- /dev/null +++ b/v2/conformance/harness_test.go @@ -0,0 +1,61 @@ +//go:build conformance + +package conformance_test + +// This file is the hand-maintained glue for the vendored standard-library flag +// test suites (flag_go*_test.go). Keep it small: it is the only part of the +// conformance harness that is allowed to diverge from upstream. + +import ( + "io" + "os" + "runtime" + "strings" + "testing" + + . "github.com/spf13/pflag/v2" +) + +// vendoredVersion is set by the init() injected into the vendored flag test +// (flag_go*_test.go) whose build constraint matches the running toolchain. It +// stays empty when the suite is built with a Go version we have not vendored a +// copy for. +var vendoredVersion string + +// TestVendoredStdlibFlagTest guards against silently testing nothing: if the +// toolchain has no matching vendored flag_test.go, no version-tagged file is +// compiled in and the rest of the suite would vacuously pass. +func TestVendoredStdlibFlagTest(t *testing.T) { + if vendoredVersion == "" { + t.Fatalf("no vendored stdlib flag test for %s; add one with: ./conformance/hack/sync.sh %s", + runtime.Version(), minorOf(runtime.Version())) + } + if !strings.HasPrefix(runtime.Version(), vendoredVersion) { + t.Fatalf("vendored copy %q does not match running toolchain %q; re-run ./conformance/hack/sync.sh", + vendoredVersion, runtime.Version()) + } +} + +// minorOf turns "go1.26.4" into "1.26" for the hint message. +func minorOf(v string) string { + v = strings.TrimPrefix(v, "go") + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return v + } + return parts[0] + "." + parts[1] +} + +// DefaultUsage mirrors flag.DefaultUsage: a snapshot of the package-level Usage +// function captured before any test overrides it. +var DefaultUsage = Usage + +// ResetForTesting mirrors flag.ResetForTesting: it replaces the global +// CommandLine with a fresh ContinueOnError FlagSet whose output is discarded, +// and installs the provided usage function. +func ResetForTesting(usage func()) { + CommandLine = NewFlagSet(os.Args[0], ContinueOnError) + CommandLine.SetOutput(io.Discard) + CommandLine.Usage = func() { Usage() } + Usage = usage +} diff --git a/v2/conformance/internal/divergence/divergence.go b/v2/conformance/internal/divergence/divergence.go new file mode 100644 index 00000000..23ea2e3b --- /dev/null +++ b/v2/conformance/internal/divergence/divergence.go @@ -0,0 +1,123 @@ +// Package divergence defines the catalogue of intentional differences between +// pflag v2's two conformance suites and the standard library flag package, plus +// the helpers the oracle uses to gate on them. +// +// It carries no build tag and has no pflag dependency, so the oracle can use it +// even while v2 itself does not yet compile under the "conformance" tag. +package divergence + +import ( + "encoding/json" + "fmt" +) + +// Category classifies why a difference exists. +type Category string + +const ( + // POSIXOverridesStdlib marks a place where pflag follows POSIX/GNU and so + // breaks a stdlib-flag expectation. POSIX wins. Permanent. + POSIXOverridesStdlib Category = "posix-overrides-stdlib" + // PflagDesignDiffers marks a message/usage/behavior difference that is + // pflag's own design choice, not mandated by POSIX. Permanent. + PflagDesignDiffers Category = "pflag-design-differs" + // PflagOmitsGNUFeature marks a GNU extension pflag does not implement. + // Permanent (unless a maintainer decides to implement it). + PflagOmitsGNUFeature Category = "pflag-omits-gnu-feature" + // NotImplementedYet marks a test that fails only because the relevant v2 API + // is not built yet. Temporary: it is tolerated leniently (fail/skip/never-run + // are all fine) and should be burned down as v2 is implemented. Unlike the + // permanent categories, a NotImplementedYet test that *passes* is not an error + // — it just means the entry can be retired. + NotImplementedYet Category = "not-implemented-yet" +) + +// Permanent reports whether a category describes a lasting divergence (as +// opposed to NotImplementedYet, which is a temporary build-out concession). +func (c Category) Permanent() bool { return c != NotImplementedYet } + +// Manifest enforcement status. +const ( + // StatusBootstrapping tolerates the conformance suite failing to build at all + // (v2 does not implement the API yet), so CI can be green during build-out. + StatusBootstrapping = "bootstrapping" + // StatusEnforcing requires the suite to build and run; a build failure is red. + StatusEnforcing = "enforcing" +) + +// Divergence is one documented difference and the conformance tests it is +// expected to make fail. +type Divergence struct { + Category Category `json:"category"` + Topic string `json:"topic"` + Stdlib string `json:"stdlib"` + Pflag string `json:"pflag"` + Refs string `json:"refs"` + AffectedTests []string `json:"affectedTests"` +} + +// Manifest is the whole catalogue. +type Manifest struct { + // Status gates whether a build failure is tolerated (see StatusBootstrapping). + Status string `json:"status"` + Divergences []Divergence `json:"divergences"` +} + +// Parse decodes a manifest from its JSON encoding. Unknown fields (such as the +// leading "_comment") are ignored. +func Parse(data []byte) (Manifest, error) { + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return Manifest{}, fmt.Errorf("parsing divergence manifest: %w", err) + } + return m, nil +} + +// BuildRequired reports whether a build failure should be treated as red. +func (m Manifest) BuildRequired() bool { return m.Status == StatusEnforcing } + +// Validate checks structural invariants of the catalogue. +func (m Manifest) Validate() error { + switch m.Status { + case StatusBootstrapping, StatusEnforcing: + default: + return fmt.Errorf("manifest status %q must be %q or %q", m.Status, StatusBootstrapping, StatusEnforcing) + } + seen := map[string]string{} + for i, d := range m.Divergences { + switch d.Category { + case POSIXOverridesStdlib, PflagDesignDiffers, PflagOmitsGNUFeature, NotImplementedYet: + default: + return fmt.Errorf("divergence %d (%q): unknown category %q", i, d.Topic, d.Category) + } + if d.Topic == "" { + return fmt.Errorf("divergence %d: missing topic", i) + } + // Permanent divergences must explain themselves; not-implemented entries + // only need the test list (they are self-explanatory and temporary). + if d.Category.Permanent() && (d.Pflag == "" || d.Refs == "") { + return fmt.Errorf("divergence %d (%q): missing required field (pflag/refs)", i, d.Topic) + } + if len(d.AffectedTests) == 0 { + return fmt.Errorf("divergence %d (%q): lists no affected tests", i, d.Topic) + } + for _, t := range d.AffectedTests { + if prev, ok := seen[t]; ok { + return fmt.Errorf("test %q listed under two divergences (%q and %q)", t, prev, d.Topic) + } + seen[t] = d.Topic + } + } + return nil +} + +// Expected maps each affected test name to the category that explains it. +func (m Manifest) Expected() map[string]Category { + out := make(map[string]Category) + for _, d := range m.Divergences { + for _, t := range d.AffectedTests { + out[t] = d.Category + } + } + return out +} diff --git a/v2/conformance/internal/testenv/testenv.go b/v2/conformance/internal/testenv/testenv.go new file mode 100644 index 00000000..4b6b5886 --- /dev/null +++ b/v2/conformance/internal/testenv/testenv.go @@ -0,0 +1,34 @@ +//go:build conformance + +// Package testenv is a minimal stand-in for the standard library's +// internal/testenv package, providing just the helpers used by the vendored +// stdlib flag test suite (see ../../flag_go*_test.go). +// +// The stdlib test suite imports "internal/testenv", which is not importable +// from outside the Go tree. The conformance sync script rewrites that import +// to point here instead. +package testenv + +import ( + "os" + "testing" +) + +// MustHaveExec checks that the current system can start new processes. The +// stdlib version skips on platforms (js/wasm, ios) where exec is unavailable; +// for our purposes a best-effort check is enough. +func MustHaveExec(t testing.TB) { + if _, err := os.Executable(); err != nil { + t.Skipf("skipping test: cannot determine executable: %v", err) + } +} + +// Executable returns the path to the current test binary, used by TestExitCode +// to re-exec itself as a child process. +func Executable(t testing.TB) string { + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable failed: %v", err) + } + return exe +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 00000000..01b315ae --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module github.com/spf13/pflag/v2 + +go 1.25 diff --git a/v2/pflag.go b/v2/pflag.go new file mode 100644 index 00000000..e2427d15 --- /dev/null +++ b/v2/pflag.go @@ -0,0 +1,6 @@ +// Package v2 is the entrypoint for pflag 2.0 +// +// See https://github.com/spf13/pflag for more info about pflag. +// +// v2 is EXTREMELY EXPERIMENTAL AND NOT FOR PRODUCTION USE AT THIS TIME. +package v2