Repository: benhoyt/goawk (go)

Issue:
Consider adding -Wexec option of mawk and gawk
`mawk` and `gawk` provide the `-Wexec` option (also written `-W exec`, and further provided by `gawk` as `-E` and `--exec`) that allows writing Awk scripts that look like standard command-line utilities by suitably processing `ARGV`. The problem with `#!/usr/bin/env -S awk -f` is that any hyphen-prefixed options provided on the command-line may be processed by the underlying Awk interpreter rather than the script (`gawk` passes along options it doesn't understand). The only way around this is to wrap the Awk call in a shell script that contains `exec env awk -f /path/to/script.awk -- "$@"`. This is what `prmindent` does in its [Makefile](https://git.sr.ht/~paulapatience/prmindent/tree/main/item/Makefile#L73). `-Wexec FILE` is effectively `-f FILE --`.

Why `-Wexec` rather than just `--exec`? From what I can gather from the `gawk` manual page, the POSIX-compliant way of adding implementation-specific options is by prefixing them with `-W`. Whether `goawk` should provide its other implementation-specific options under `-W` (and possibly only under `-W`, as `mawk` does) is something to be considered separately. In order to maximize compatibility with `mawk`, this option should be provided at least as `-Wexec` and `-W exec`.

The hashbang line of `prmindent` is `#!/usr/bin/env -S awk -Wexec` by default. This change would allow `goawk` to be a drop-in replacement for the system `awk` at least in this case.

Pull request description:
Add support for -E argument (like Gawk's -E/--exec)
It's like -f but stops processing other arguments and disables
foo=bar arguments in the rest of the args. Like Gawk's -E/--exec and
mawk's "-W exec" option.

Also update test runner to run Gawk and GoAWK tests in separate
sub-tests, so one breaking doesn't stop the other from running.

Fixes #139 (thanks @paulapatience)

Patch:
diff --git a/goawk.go b/goawk.go
index 8c20d84..a9a5d74 100644
--- a/goawk.go
+++ b/goawk.go
@@ -57,6 +57,7 @@ Additional GoAWK arguments:
   -d                print parsed syntax tree to stderr (debug mode)
   -da               print virtual machine assembly instructions to stderr
   -dt               print variable type information to stderr
+  -E progfile       load program, treat as last option, disable var=value args
   -H                parse header row and enable @"field" in CSV input mode
   -h, --help        show this help message
   -i mode           parse input into fields using CSV format (ignore FS and RS)
@@ -82,8 +83,10 @@ func main() {
 	inputMode := ""
 	outputMode := ""
 	header := false
+	noArgVars := false
 
 	var i int
+argsLoop:
 	for i = 1; i < len(os.Args); i++ {
 		// Stop on explicit end of args or first arg not prefixed with "-"
 		arg := os.Args[i]
@@ -96,6 +99,15 @@ func main() {
 		}
 
 		switch arg {
+		case "-E":
+			if i+1 >= len(os.Args) {
+				errorExitf("flag needs an argument: -E")
+			}
+			i++
+			progFiles = append(progFiles, os.Args[i])
+			noArgVars = true
+			i++
+			break argsLoop
 		case "-F":
 			if i+1 >= len(os.Args) {
 				errorExitf("flag needs an argument: -F")
@@ -154,6 +166,11 @@ func main() {
 			os.Exit(0)
 		default:
 			switch {
+			case strings.HasPrefix(arg, "-E"):
+				progFiles = append(progFiles, arg[2:])
+				noArgVars = true
+				i++
+				break argsLoop
 			case strings.HasPrefix(arg, "-F"):
 				fieldSep = arg[2:]
 			case strings.HasPrefix(arg, "-f"):
@@ -259,14 +276,15 @@ func main() {
 	}
 
 	config := &interp.Config{
-		Argv0: filepath.Base(os.Args[0]),
-		Args:  expandWildcardsOnWindows(args),
+		Argv0:     filepath.Base(os.Args[0]),
+		Args:      expandWildcardsOnWindows(args),
+		NoArgVars: noArgVars,
+		Output:    stdout,
 		Vars: []string{
 			"FS", fieldSep,
 			"INPUTMODE", inputMode,
 			"OUTPUTMODE", outputMode,
 		},
-		Output: stdout,
 	}
 	for _, v := range vars {
 		equals := strings.IndexByte(v, '=')
diff --git a/interp/interp.go b/interp/interp.go
index 8a2324b..ab41fe7 100644
--- a/interp/interp.go
+++ b/interp/interp.go
@@ -87,6 +87,7 @@ type interp struct {
 	noFileReads   bool
 	shellCommand  []string
 	csvOutput     *bufio.Writer
+	noArgVars     bool
 
 	// Scalars, arrays, and function state
 	globals     []value
@@ -185,8 +186,14 @@ type Config struct {
 	// Input arguments (usually filenames): empty slice means read
 	// only from Stdin, and a filename of "-" means read from Stdin
 	// instead of a real file.
+	//
+	// Arguments of the form "var=value" are treated as variable
+	// assignments.
 	Args []string
 
+	// Set to true to disable "var=value" assignments in Args.
+	NoArgVars bool
+
 	// List of name-value pairs for variables to set before executing
 	// the program (useful for setting FS and other built-in
 	// variables, for example []string{"FS", ",", "OFS", ","}).
@@ -432,6 +439,7 @@ func (p *interp) setExecuteConfig(config *Config) error {
 	for i, arg := range config.Args {
 		p.setArrayValue(ast.ScopeGlobal, argvIndex, strconv.Itoa(i+1), numStr(arg))
 	}
+	p.noArgVars = config.NoArgVars
 	p.filenameIndex = 1
 	p.hadFiles = false
 	for i := 0; i < len(config.Vars); i += 2 {
diff --git a/interp/io.go b/interp/io.go
index e8deecd..ec23c21 100644
--- a/interp/io.go
+++ b/interp/io.go
@@ -738,7 +738,10 @@ func (p *interp) nextLine() (string, error) {
 				p.filenameIndex++
 
 				// Is it actually a var=value assignment?
-				matches := varRegex.FindStringSubmatch(filename)
+				var matches []string
+				if !p.noArgVars {
+					matches = varRegex.FindStringSubmatch(filename)
+				}
 				if len(matches) >= 3 {
 					// Yep, set variable to value and keep going
 					name, val := matches[1], matches[2]


Test patch:
diff --git a/goawk_test.go b/goawk_test.go
index a19b51b..a3d78b4 100644
--- a/goawk_test.go
+++ b/goawk_test.go
@@ -403,6 +403,13 @@ func TestCommandLine(t *testing.T) {
 		{[]string{`BEGIN { print(ARGV[1]<2, ARGV[2]<2); ARGV[1]="10"; ARGV[2]="10x"; print(ARGV[1]<2, ARGV[2]<2) }`,
 			"10", "10x"}, "", "0 1\n1 1\n", ""},
 
+		// -E argument
+		{[]string{"-E", "testdata/awc.awk", "-w", "testdata/awc.awk"}, "", "95 \n", ""},
+		{[]string{"-Etestdata/awc.awk", "-w", "testdata/awc.awk"}, "", "95 \n", ""},
+		{[]string{"-E", "testdata/awc.awk", "-w", "--", "testdata/awc.awk"}, "", "95 \n", ""},
+		{[]string{"-E", "testdata/awc.awk", "--", "-l"}, "", "", `file "-l" not found`},
+		{[]string{"-E", "testdata/awc.awk", "foo=bar"}, "", "", `file "foo=bar" not found`},
+
 		// Error handling
 		{[]string{}, "", "", "usage: goawk [-F fs] [-v var=value] [-f progfile | 'prog'] [file ...]"},
 		{[]string{"-F"}, "", "", "flag needs an argument: -F"},
@@ -460,46 +467,50 @@ func runGoAWK(args []string, stdin string) (stdout, stderr string, err error) {
 }
 
 func runAWKs(t *testing.T, testArgs []string, testStdin, testOutput, testError string) {
-	var args []string
-	if strings.Contains(awkExe, "gawk") {
-		args = append(args, "--posix")
-	}
-	args = append(args, testArgs...)
-	cmd := exec.Command(awkExe, testArgs...)
-	if testStdin != "" {
-		cmd.Stdin = strings.NewReader(testStdin)
-	}
-	errBuf := &bytes.Buffer{}
-	cmd.Stderr = errBuf
-	output, err := cmd.Output()
-	if err != nil {
-		if testError == "" {
-			t.Fatalf("expected no error, got AWK error: %v (%s)", err, errBuf.String())
+	t.Run("awk", func(t *testing.T) {
+		var args []string
+		if strings.Contains(awkExe, "gawk") {
+			args = append(args, "--posix")
 		}
-	} else {
-		if testError != "" {
-			t.Fatalf("expected AWK error, got none")
+		args = append(args, testArgs...)
+		cmd := exec.Command(awkExe, testArgs...)
+		if testStdin != "" {
+			cmd.Stdin = strings.NewReader(testStdin)
 		}
-	}
-	stdout := string(normalizeNewlines(output))
-	if stdout != testOutput {
-		t.Fatalf("expected AWK to give %q, got %q", testOutput, stdout)
-	}
-
-	stdout, stderr, err := runGoAWK(testArgs, testStdin)
-	if err != nil {
-		stderr = strings.TrimSpace(stderr)
-		if stderr != testError {
-			t.Fatalf("expected GoAWK error %q, got %q", testError, stderr)
+		errBuf := &bytes.Buffer{}
+		cmd.Stderr = errBuf
+		output, err := cmd.Output()
+		if err != nil {
+			if testError == "" {
+				t.Fatalf("expected no error, got AWK error: %v (%s)", err, errBuf.String())
+			}
+		} else {
+			if testError != "" {
+				t.Fatalf("expected AWK error, got none")
+			}
 		}
-	} else {
-		if testError != "" {
-			t.Fatalf("expected GoAWK error %q, got none", testError)
+		stdout := string(normalizeNewlines(output))
+		if stdout != testOutput {
+			t.Fatalf("AWK expected/got:\n%q\n%q", testOutput, stdout)
 		}
-	}
-	if stdout != testOutput {
-		t.Fatalf("expected GoAWK to give %q, got %q", testOutput, stdout)
-	}
+	})
+
+	t.Run("goawk", func(t *testing.T) {
+		stdout, stderr, err := runGoAWK(testArgs, testStdin)
+		if err != nil {
+			stderr = strings.TrimSpace(stderr)
+			if stderr != testError {
+				t.Fatalf("expected GoAWK error %q, got %q", testError, stderr)
+			}
+		} else {
+			if testError != "" {
+				t.Fatalf("expected GoAWK error %q, got none", testError)
+			}
+		}
+		if stdout != testOutput {
+			t.Fatalf("GoAWK expected/got:\n%q\n%q", testOutput, stdout)
+		}
+	})
 }
 
 func TestWildcards(t *testing.T) {
diff --git a/testdata/awc.awk b/testdata/awc.awk
new file mode 100644
index 0000000..de16371
--- /dev/null
+++ b/testdata/awc.awk
@@ -0,0 +1,17 @@
+BEGIN {
+  for (i = 1; i < ARGC; i++) {
+    if (ARGV[i] == "--") {
+      delete ARGV[i++]
+      break
+    }
+    else if (ARGV[i] !~ /^-./) break
+    else if (ARGV[i] == "-c") c = 1
+    else if (ARGV[i] == "-w") w = 1
+    else if (ARGV[i] == "-l") l = 1
+    else printf "awc: unknown option: %s\n", ARGV[i] >"/dev/stderr"
+    delete ARGV[i]
+  }
+  if (!c && !w && !l) c = w = l = 1
+}
+{ cs += length(); ws += NF; ls++ }
+END { printf "%s%s%s\n", l?ls" ":"", w?ws" ":"", c?cs" ":"" }