View Javadoc
1   package io.jawk;
2   
3   /*-
4    * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
5    * Jawk
6    * ჻჻჻჻჻჻
7    * Copyright (C) 2006 - 2026 MetricsHub
8    * ჻჻჻჻჻჻
9    * This program is free software: you can redistribute it and/or modify
10   * it under the terms of the GNU Lesser General Public License as
11   * published by the Free Software Foundation, either version 3 of the
12   * License, or (at your option) any later version.
13   *
14   * This program is distributed in the hope that it will be useful,
15   * but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17   * GNU General Lesser Public License for more details.
18   *
19   * You should have received a copy of the GNU General Lesser Public
20   * License along with this program.  If not, see
21   * <http://www.gnu.org/licenses/lgpl-3.0.html>.
22   * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
23   */
24  
25  import static org.junit.Assert.assertArrayEquals;
26  import static org.junit.Assert.assertEquals;
27  import static org.junit.Assume.assumeNoException;
28  import static org.junit.Assume.assumeTrue;
29  
30  import java.io.BufferedReader;
31  import java.io.BufferedWriter;
32  import java.io.ByteArrayInputStream;
33  import java.io.ByteArrayOutputStream;
34  import java.io.File;
35  import java.io.IOException;
36  import java.io.InputStream;
37  import java.io.InputStreamReader;
38  import java.io.PrintStream;
39  import java.io.Reader;
40  import java.io.StringReader;
41  import java.io.UncheckedIOException;
42  import java.nio.charset.StandardCharsets;
43  import java.nio.file.Files;
44  import java.nio.file.Path;
45  import java.util.ArrayList;
46  import java.util.Arrays;
47  import java.util.Collection;
48  import java.util.Collections;
49  import java.util.LinkedHashMap;
50  import java.util.List;
51  import java.util.Locale;
52  import java.util.Map;
53  import java.util.function.Function;
54  import java.util.stream.Collectors;
55  import java.util.stream.Stream;
56  import io.jawk.ext.JawkExtension;
57  import io.jawk.jrt.InputSource;
58  
59  /**
60   * Reusable helpers for building and executing Jawk tests. This consolidates the
61   * logic that was historically duplicated across different test suites, so that
62   * all tests share the same approach for managing temporary files, providing
63   * input, and capturing the results of either {@link Awk} or {@link Cli}
64   * executions. The class exposes fluent builders ({@link #awkTest(String)} and
65   * {@link #cliTest(String)}) that let tests describe their scripts, inputs, and
66   * expectations declaratively before executing or asserting the results.
67   */
68  public final class AwkTestSupport {
69  
70  	private static final boolean IS_POSIX = !System
71  			.getProperty("os.name", "")
72  			.toLowerCase(Locale.ROOT)
73  			.contains("win");
74  
75  	private static final Path SHARED_TEMP_DIR;
76  
77  	static {
78  		try {
79  			SHARED_TEMP_DIR = Files.createTempDirectory("jawk-shared");
80  			SHARED_TEMP_DIR.toFile().deleteOnExit();
81  		} catch (IOException ex) {
82  			throw new ExceptionInInitializerError(ex);
83  		}
84  	}
85  
86  	private AwkTestSupport() {}
87  
88  	/**
89  	 * Creates a builder for a unit test that exercises the {@link Awk} API directly.
90  	 * The returned builder can be configured with scripts, inputs, operands, and
91  	 * expectations before executing the test.
92  	 *
93  	 * @param description human readable description used in assertion messages
94  	 * @return a builder configured with the provided description
95  	 */
96  	public static AwkTestBuilder awkTest(String description) {
97  		return new AwkTestBuilder(description);
98  	}
99  
100 	/**
101 	 * Creates a builder for a unit test that exercises the {@link Cli} entry
102 	 * point. The builder records all inputs and expectations before invoking the
103 	 * CLI.
104 	 *
105 	 * @param description human readable description used in assertion messages
106 	 * @return a builder configured with the provided description
107 	 */
108 	public static CliTestBuilder cliTest(String description) {
109 		return new CliTestBuilder(description);
110 	}
111 
112 	/**
113 	 * Returns the shared temporary directory that builders use when a test opts
114 	 * into creating temporary files. Tests can reference this value directly when
115 	 * they need deterministic paths outside the per-test sandbox.
116 	 *
117 	 * @return the lazily created shared temporary directory
118 	 */
119 	public static Path sharedTempDirectory() {
120 		return SHARED_TEMP_DIR;
121 	}
122 
123 	/**
124 	 * Represents a fully configured test case produced by one of the builders.
125 	 * Implementations know how to prepare the execution environment, run the
126 	 * script, and assert expectations.
127 	 */
128 	public interface ConfiguredTest {
129 		/**
130 		 * Provides a human readable description that is included in assertion
131 		 * messages.
132 		 *
133 		 * @return the description defined by the builder
134 		 */
135 		String description();
136 
137 		/**
138 		 * Skips the test when the current environment cannot satisfy the test
139 		 * prerequisites (for instance POSIX specific behaviour).
140 		 */
141 		void assumeSupported();
142 
143 		/**
144 		 * Executes the configured test case and returns the captured result
145 		 * without asserting it.
146 		 *
147 		 * @return the captured output, exit code, and expected values
148 		 * @throws Exception when executing the test fails unexpectedly
149 		 */
150 		TestResult run() throws Exception;
151 
152 		/**
153 		 * Executes the configured test case and immediately asserts that the
154 		 * observed result matches the configured expectations.
155 		 *
156 		 * @throws Exception when executing the test fails unexpectedly
157 		 */
158 		default void runAndAssert() throws Exception {
159 			assumeSupported();
160 			run().assertExpected();
161 		}
162 	}
163 
164 	/**
165 	 * Captures the outcome of executing a configured test including the raw
166 	 * output, exit code, and any expectations configured on the builder. Instances
167 	 * can assert the recorded values against expectations.
168 	 */
169 	public static final class TestResult {
170 		private final String description;
171 		private final String output;
172 		private final String errorOutput;
173 		private final int exitCode;
174 		private final String expectedOutput;
175 		private final List<String> expectedLines;
176 		private final Integer expectedExitCode;
177 		private final Class<? extends Throwable> expectedException;
178 		private final Throwable thrownException;
179 
180 		TestResult(
181 				String description,
182 				String output,
183 				String errorOutput,
184 				int exitCode,
185 				String expectedOutput,
186 				List<String> expectedLines,
187 				Integer expectedExitCode,
188 				Class<? extends Throwable> expectedException,
189 				Throwable thrownException) {
190 			this.description = description;
191 			this.output = output;
192 			this.errorOutput = errorOutput;
193 			this.exitCode = exitCode;
194 			this.expectedOutput = expectedOutput;
195 			this.expectedLines = expectedLines != null ? Collections.unmodifiableList(new ArrayList<>(expectedLines)) : null;
196 			this.expectedExitCode = expectedExitCode;
197 			this.expectedException = expectedException;
198 			this.thrownException = thrownException;
199 		}
200 
201 		/**
202 		 * Returns the description that was supplied when the test was defined.
203 		 *
204 		 * @return the human readable description
205 		 */
206 		public String description() {
207 			return description;
208 		}
209 
210 		/**
211 		 * Returns the captured stdout of the test execution.
212 		 *
213 		 * @return the captured output as a UTF-8 string
214 		 */
215 		public String output() {
216 			return output;
217 		}
218 
219 		/**
220 		 * Returns the captured stderr of the test execution.
221 		 *
222 		 * @return the captured error output as a UTF-8 string
223 		 */
224 		public String errorOutput() {
225 			return errorOutput;
226 		}
227 
228 		/**
229 		 * Returns the exit code reported by the execution.
230 		 *
231 		 * @return the exit code observed at runtime
232 		 */
233 		public int exitCode() {
234 			return exitCode;
235 		}
236 
237 		/**
238 		 * Returns the captured output split into individual lines. Trailing
239 		 * newline characters are ignored and Windows style line endings are
240 		 * normalised.
241 		 *
242 		 * @return the output split into lines, or an empty array when no output
243 		 *         was produced
244 		 */
245 		public String[] lines() {
246 			List<String> split = readOutputLines(output);
247 			return split.toArray(new String[0]);
248 		}
249 
250 		/**
251 		 * Verifies that the captured output, exit code, or thrown exception match
252 		 * the expectations defined in the builder.
253 		 */
254 		public void assertExpected() {
255 			if (expectedException != null) {
256 				if (thrownException == null) {
257 					throw new AssertionError(
258 							"Expected exception "
259 									+ expectedException.getName()
260 									+ " for "
261 									+ description
262 									+ " but execution completed successfully");
263 				}
264 				if (!expectedException.isInstance(thrownException)) {
265 					throw new AssertionError(
266 							"Expected exception "
267 									+ expectedException.getName()
268 									+ " for "
269 									+ description
270 									+ " but got "
271 									+ thrownException.getClass().getName());
272 				}
273 				return;
274 			}
275 			if (expectedLines != null) {
276 				List<String> actualLines = readOutputLines(output);
277 				assertArrayEquals(
278 						"Unexpected output for " + description,
279 						expectedLines.toArray(new String[0]),
280 						actualLines.toArray(new String[0]));
281 			} else if (expectedOutput != null) {
282 				assertEquals("Unexpected output for " + description, expectedOutput, output);
283 			}
284 			if (expectedExitCode != null) {
285 				assertEquals("Unexpected exit code for " + description, expectedExitCode.intValue(), exitCode);
286 			} else {
287 				assertEquals("Unexpected exit code for " + description, 0, exitCode);
288 			}
289 		}
290 
291 		private static List<String> readOutputLines(String output) {
292 			if (output.isEmpty()) {
293 				return Collections.emptyList();
294 			}
295 			List<String> lines = new ArrayList<>();
296 			try (BufferedReader reader = new BufferedReader(new StringReader(output))) {
297 				String line;
298 				while ((line = reader.readLine()) != null) {
299 					lines.add(line);
300 				}
301 			} catch (IOException ex) {
302 				throw new UncheckedIOException("Failed to split captured output", ex);
303 			}
304 			return Collections.unmodifiableList(lines);
305 		}
306 
307 		public String expectedOutput() {
308 			return expectedOutput;
309 		}
310 
311 		/**
312 		 * Returns the expected exit code configured on the builder.
313 		 *
314 		 * @return the expected exit code or {@code null} when the default of zero
315 		 *         should be asserted
316 		 */
317 		public Integer expectedExitCode() {
318 			return expectedExitCode;
319 		}
320 
321 		/**
322 		 * Returns the exception that was thrown while executing the test.
323 		 *
324 		 * @return the thrown exception or {@code null} when execution completed
325 		 *         normally
326 		 */
327 		public Throwable thrownException() {
328 			return thrownException;
329 		}
330 
331 		/**
332 		 * Returns the exception type that was expected during execution.
333 		 *
334 		 * @return the expected exception type or {@code null} when no exception
335 		 *         was expected
336 		 */
337 		public Class<? extends Throwable> expectedException() {
338 			return expectedException;
339 		}
340 	}
341 
342 	/**
343 	 * Fluent builder for tests that execute {@link Awk} directly. The builder
344 	 * exposes helpers to preassign variables, provide extensions, and otherwise
345 	 * mirror the runtime configuration used when embedding Jawk.
346 	 */
347 	public static final class AwkTestBuilder extends BaseTestBuilder<AwkTestBuilder> {
348 		private final Map<String, Object> preAssignments = new LinkedHashMap<>();
349 		private Awk customAwk;
350 		private final List<JawkExtension> extensions = new ArrayList<>();
351 		private InputSource inputSource;
352 		private Reader scriptReader;
353 		private Path scriptPath;
354 
355 		private AwkTestBuilder(String description) {
356 			super(description);
357 		}
358 
359 		/**
360 		 * Sets the AWK script to execute using a raw {@link String}. Any
361 		 * placeholder tokens in the script are resolved before execution.
362 		 *
363 		 * @param script the script contents
364 		 * @return this builder for method chaining
365 		 */
366 		@Override
367 		public AwkTestBuilder script(String script) {
368 			scriptReader = null;
369 			scriptPath = null;
370 			return super.script(script);
371 		}
372 
373 		/**
374 		 * Sets the AWK script to execute using a {@link Reader}. The reader is
375 		 * consumed directly by the compiler when the test runs.
376 		 *
377 		 * @param reader the script reader
378 		 * @return this builder for method chaining
379 		 * @throws IllegalArgumentException when {@code reader} is {@code null}
380 		 */
381 		public AwkTestBuilder script(Reader reader) {
382 			if (reader == null) {
383 				throw new IllegalArgumentException("reader must not be null");
384 			}
385 			script = null;
386 			scriptReader = reader;
387 			scriptPath = null;
388 			return this;
389 		}
390 
391 		/**
392 		 * Sets the AWK script to execute using a UTF-8 input stream. The stream is
393 		 * wrapped in a reader and consumed directly by the compiler when the test
394 		 * runs.
395 		 *
396 		 * @param scriptStream the stream supplying the script contents
397 		 * @return this builder for method chaining
398 		 * @throws IllegalArgumentException when {@code scriptStream} is
399 		 *         {@code null}
400 		 */
401 		public AwkTestBuilder script(InputStream scriptStream) {
402 			if (scriptStream == null) {
403 				throw new IllegalArgumentException("scriptStream must not be null");
404 			}
405 			return script(new InputStreamReader(scriptStream, StandardCharsets.UTF_8));
406 		}
407 
408 		/**
409 		 * Sets the AWK script to execute from a UTF-8 file.
410 		 *
411 		 * @param path path to the script file
412 		 * @return this builder for method chaining
413 		 * @throws IllegalArgumentException when {@code path} is {@code null}
414 		 */
415 		public AwkTestBuilder script(Path path) {
416 			if (path == null) {
417 				throw new IllegalArgumentException("path must not be null");
418 			}
419 			script = null;
420 			scriptReader = null;
421 			scriptPath = path;
422 			return this;
423 		}
424 
425 		/**
426 		 * Registers a value to pre-assign to a variable before the script is
427 		 * executed.
428 		 *
429 		 * @param name the variable name
430 		 * @param value the value to expose to the script
431 		 * @return this builder for method chaining
432 		 */
433 		public AwkTestBuilder preassign(String name, Object value) {
434 			preAssignments.put(name, value);
435 			return this;
436 		}
437 
438 		/**
439 		 * Supplies an {@link Awk} instance to use when invoking the script.
440 		 *
441 		 * @param awkEngine the engine to execute the script with
442 		 * @return this builder for method chaining
443 		 * @throws IllegalArgumentException when {@code awkEngine} is {@code null}
444 		 */
445 		public AwkTestBuilder withAwk(Awk awkEngine) {
446 			if (awkEngine == null) {
447 				throw new IllegalArgumentException("Awk instance must not be null");
448 			}
449 			this.customAwk = awkEngine;
450 			return this;
451 		}
452 
453 		/**
454 		 * Adds extensions that will be loaded when creating the {@link Awk}
455 		 * instance used by this test.
456 		 *
457 		 * @param extensionsParam the extensions to enable, ignored when
458 		 *        {@code null}
459 		 * @return this builder for method chaining
460 		 */
461 		public AwkTestBuilder withExtensions(JawkExtension... extensionsParam) {
462 			if (extensionsParam != null) {
463 				extensions.addAll(Arrays.asList(extensionsParam));
464 			}
465 			return this;
466 		}
467 
468 		/**
469 		 * Adds extensions that will be loaded when creating the {@link Awk}
470 		 * instance used by this test.
471 		 *
472 		 * @param extensionsParam the extensions to enable, ignored when
473 		 *        {@code null}
474 		 * @return this builder for method chaining
475 		 */
476 		public AwkTestBuilder withExtensions(Collection<? extends JawkExtension> extensionsParam) {
477 			if (extensionsParam != null) {
478 				extensions.addAll(extensionsParam);
479 			}
480 			return this;
481 		}
482 
483 		/**
484 		 * Configures a structured input source consumed by the runtime instead of
485 		 * stdin.
486 		 *
487 		 * @param inputSourceParam input source to consume
488 		 * @return this builder for method chaining
489 		 * @throws IllegalArgumentException when {@code inputSourceParam} is
490 		 *         {@code null}
491 		 */
492 		public AwkTestBuilder withInputSource(InputSource inputSourceParam) {
493 			if (inputSourceParam == null) {
494 				throw new IllegalArgumentException("InputSource must not be null");
495 			}
496 			this.inputSource = inputSourceParam;
497 			return this;
498 		}
499 
500 		@Override
501 		protected AwkTestCase buildTestCase(
502 				TestLayout layout,
503 				Map<String, String> files,
504 				Map<String, String> symlinks,
505 				List<String> operands,
506 				List<String> placeholders) {
507 			if (useTempDir && !preAssignments.containsKey("TEMPDIR")) {
508 				preAssignments.put("TEMPDIR", SHARED_TEMP_DIR.toString());
509 			}
510 			return new AwkTestCase(
511 					layout,
512 					files,
513 					symlinks,
514 					operands,
515 					placeholders,
516 					requiresPosix,
517 					preAssignments,
518 					customAwk,
519 					extensions,
520 					inputSource,
521 					scriptReader,
522 					scriptPath);
523 		}
524 	}
525 
526 	/**
527 	 * Fluent builder for tests that exercise the {@link Cli} entry point. The
528 	 * builder takes care of wiring command-line arguments, assignments, and
529 	 * expectations before invoking the CLI.
530 	 */
531 	public static final class CliTestBuilder extends BaseTestBuilder<CliTestBuilder> {
532 		private final List<String> argumentSpecs = new ArrayList<>();
533 		private final Map<String, Object> assignments = new LinkedHashMap<>();
534 		private final Map<String, String> environment = new LinkedHashMap<>();
535 		private boolean redirectErrorStream;
536 		private InputStream stdinStream;
537 
538 		private CliTestBuilder(String description) {
539 			super(description);
540 		}
541 
542 		/**
543 		 * Supplies the raw standard-input stream handed to the CLI under test.
544 		 * Use this instead of {@link #stdin(String)} when the test must observe
545 		 * interactions with the stream itself, such as tracking whether the CLI
546 		 * closes the caller-provided stream.
547 		 *
548 		 * @param stream the input stream the CLI reads program input from
549 		 * @return this builder for method chaining
550 		 */
551 		public CliTestBuilder stdin(InputStream stream) {
552 			this.stdinStream = stream;
553 			return this;
554 		}
555 
556 		/**
557 		 * Merges the CLI's stderr into the captured stdout, like a shell
558 		 * {@code 2>&1} redirection. Use this for cases whose expected transcript
559 		 * interleaves warnings with regular output, such as the gawk suite's
560 		 * {@code .ok} files.
561 		 *
562 		 * @return this builder for method chaining
563 		 */
564 		public CliTestBuilder redirectErrorStream() {
565 			redirectErrorStream = true;
566 			return this;
567 		}
568 
569 		/**
570 		 * Adds raw command-line arguments to supply to the CLI when the test is
571 		 * executed. Path placeholders are resolved at runtime.
572 		 *
573 		 * @param args the arguments to add
574 		 * @return this builder for method chaining
575 		 */
576 		public CliTestBuilder argument(String... args) {
577 			argumentSpecs.addAll(Arrays.asList(args));
578 			return this;
579 		}
580 
581 		/**
582 		 * Preassigns a variable using {@code -v} style CLI options before
583 		 * executing the script.
584 		 *
585 		 * @param name the variable name
586 		 * @param value the value to expose to the script
587 		 * @return this builder for method chaining
588 		 */
589 		public CliTestBuilder preassign(String name, Object value) {
590 			assignments.put(name, value);
591 			return this;
592 		}
593 
594 		/**
595 		 * Adds one environment variable that should be visible to the CLI during
596 		 * execution. Placeholder tokens in the value are resolved at runtime.
597 		 *
598 		 * @param name environment variable name
599 		 * @param value environment variable value
600 		 * @return this builder for method chaining
601 		 */
602 		public CliTestBuilder env(String name, String value) {
603 			environment.put(name, value);
604 			return this;
605 		}
606 
607 		/**
608 		 * Adds several environment variables that should be visible to the CLI
609 		 * during execution. Placeholder tokens in the values are resolved at
610 		 * runtime.
611 		 *
612 		 * @param values environment variables to expose
613 		 * @return this builder for method chaining
614 		 */
615 		public CliTestBuilder env(Map<String, String> values) {
616 			if (values != null) {
617 				environment.putAll(values);
618 			}
619 			return this;
620 		}
621 
622 		@Override
623 		protected CliTestCase buildTestCase(
624 				TestLayout layout,
625 				Map<String, String> files,
626 				Map<String, String> symlinks,
627 				List<String> operands,
628 				List<String> placeholders) {
629 			if (useTempDir && !assignments.containsKey("TEMPDIR")) {
630 				assignments.put("TEMPDIR", SHARED_TEMP_DIR.toString());
631 			}
632 			return new CliTestCase(
633 					layout,
634 					files,
635 					symlinks,
636 					operands,
637 					placeholders,
638 					requiresPosix,
639 					argumentSpecs,
640 					assignments,
641 					environment,
642 					redirectErrorStream,
643 					stdinStream);
644 		}
645 	}
646 
647 	/**
648 	 * Shared implementation for the fluent builders exposed by
649 	 * {@link AwkTestSupport}. Subclasses specialise the execution behaviour while
650 	 * reusing the configuration helpers defined here.
651 	 *
652 	 * @param <B> the builder type used for fluent chaining
653 	 */
654 	private abstract static class BaseTestBuilder<B extends BaseTestBuilder<B>> {
655 		protected final String description;
656 		protected String script;
657 		protected String stdin;
658 		protected final Map<String, String> fileContents = new LinkedHashMap<>();
659 		protected final Map<String, String> symbolicLinks = new LinkedHashMap<>();
660 		protected final List<String> operandSpecs = new ArrayList<>();
661 		protected final List<String> pathPlaceholders = new ArrayList<>();
662 		protected String expectedOutput;
663 		protected List<String> expectedLines;
664 		protected Integer expectedExitCode;
665 		protected Class<? extends Throwable> expectedException;
666 		protected boolean requiresPosix;
667 		protected boolean useTempDir;
668 		protected List<Function<String, String>> postProcessors = new ArrayList<>();
669 
670 		BaseTestBuilder(String description) {
671 			this.description = description;
672 		}
673 
674 		/**
675 		 * Sets the AWK script to execute using a raw {@link String}. Any
676 		 * placeholder tokens in the script are resolved before execution.
677 		 *
678 		 * @param script the script contents
679 		 * @return this builder for method chaining
680 		 */
681 		@SuppressWarnings("unchecked")
682 		public B script(String script) {
683 			this.script = script;
684 			return (B) this;
685 		}
686 
687 		/**
688 		 * Provides data that will be delivered on standard input when the script
689 		 * runs.
690 		 *
691 		 * @param stdin the content to stream into standard input
692 		 * @return this builder for method chaining
693 		 */
694 		@SuppressWarnings("unchecked")
695 		public B stdin(String stdin) {
696 			this.stdin = stdin;
697 			return (B) this;
698 		}
699 
700 		/**
701 		 * Adds a temporary file to create before the script runs. The file is
702 		 * written inside the per-test temporary directory and can be referenced
703 		 * with {@code {{name}}} placeholders.
704 		 *
705 		 * @param name the relative path within the temporary directory
706 		 * @param contents the file contents to write as UTF-8
707 		 * @return this builder for method chaining
708 		 */
709 		@SuppressWarnings("unchecked")
710 		public B file(String name, String contents) {
711 			fileContents.put(name, contents);
712 			return (B) this;
713 		}
714 
715 		/**
716 		 * Adds a symbolic link inside the per-test temporary directory. The target
717 		 * is resolved relative to that directory and should normally name a file
718 		 * configured through {@link #file(String, String)}. The test is skipped
719 		 * when the platform does not permit symbolic-link creation.
720 		 *
721 		 * @param name relative path of the symbolic link
722 		 * @param target relative path of its target
723 		 * @return this builder for method chaining
724 		 */
725 		@SuppressWarnings("unchecked")
726 		public B symlink(String name, String target) {
727 			symbolicLinks.put(name, target);
728 			return (B) this;
729 		}
730 
731 		/**
732 		 * Adds operands to pass to the script when it is executed. Placeholders
733 		 * are resolved at runtime.
734 		 *
735 		 * @param operands the operands to add
736 		 * @return this builder for method chaining
737 		 */
738 		@SuppressWarnings("unchecked")
739 		public B operand(String... operands) {
740 			operandSpecs.addAll(Arrays.asList(operands));
741 			return (B) this;
742 		}
743 
744 		/**
745 		 * Reserves an empty path inside the temporary directory and exposes its
746 		 * location via a placeholder.
747 		 *
748 		 * @param placeholder the placeholder identifier to resolve in scripts or
749 		 *        inputs
750 		 * @return this builder for method chaining
751 		 */
752 		@SuppressWarnings("unchecked")
753 		public B path(String placeholder) {
754 			pathPlaceholders.add(placeholder);
755 			return (B) this;
756 		}
757 
758 		/**
759 		 * Adds a post-processing function to the output of the AWK script
760 		 * that will be used before running the assertions.
761 		 *
762 		 * @param processor function used for post-processing the output of the AWK script
763 		 * @return this builder for method chaining
764 		 */
765 		@SuppressWarnings("unchecked")
766 		public B postProcessWith(Function<String, String> processor) {
767 			if (processor != null) {
768 				postProcessors.add(processor);
769 			}
770 			return (B) this;
771 		}
772 
773 		/**
774 		 * Declares the exact output expected from the script.
775 		 *
776 		 * @param expected the expected output
777 		 * @return this builder for method chaining
778 		 */
779 		@SuppressWarnings("unchecked")
780 		public B expect(String expected) {
781 			this.expectedOutput = expected;
782 			this.expectedLines = null;
783 			return (B) this;
784 		}
785 
786 		/**
787 		 * Declares the expected output using individual lines. A trailing newline
788 		 * is automatically appended when at least one line is supplied.
789 		 *
790 		 * @param lines the expected output lines
791 		 * @return this builder for method chaining
792 		 */
793 		public B expectLines(String... lines) {
794 			return expectLines(Arrays.asList(Arrays.copyOf(lines, lines.length)));
795 		}
796 
797 		/**
798 		 * Declares the expected output using individual lines. A trailing newline
799 		 * is automatically appended when at least one line is supplied.
800 		 *
801 		 * @param lines the expected output lines
802 		 * @return this builder for method chaining
803 		 */
804 		@SuppressWarnings("unchecked")
805 		public B expectLines(List<String> lines) {
806 			this.expectedLines = new ArrayList<>(lines);
807 			this.expectedOutput = null;
808 			return (B) this;
809 		}
810 
811 		/**
812 		 * Declares the expected output using individual lines. A trailing newline
813 		 * is automatically appended when at least one line is supplied.
814 		 *
815 		 * @param File instance from which we will extract the lines to be matched with
816 		 * @return this builder for method chaining
817 		 * @throws IOException if file cannot be read
818 		 */
819 		public B expectLines(File expectedResultFile) throws IOException {
820 			return expectLines(expectedResultFile.toPath());
821 		}
822 
823 		/**
824 		 * Declares the expected output using individual lines. A trailing newline
825 		 * is automatically appended when at least one line is supplied.
826 		 *
827 		 * @param Path to the file from which we will extract the lines to be matched with
828 		 * @return this builder for method chaining
829 		 * @throws IOException if file cannot be read
830 		 */
831 		public B expectLines(Path expectedResultPath) throws IOException {
832 			return expectLines(Files.readAllLines(expectedResultPath, StandardCharsets.UTF_8));
833 		}
834 
835 		/**
836 		 * Declares the expected exit code for the execution.
837 		 *
838 		 * @param code the exit code to expect
839 		 * @return this builder for method chaining
840 		 */
841 		@SuppressWarnings("unchecked")
842 		public B expectExit(int code) {
843 			this.expectedExitCode = code;
844 			return (B) this;
845 		}
846 
847 		/**
848 		 * Declares the exception type that the script is expected to throw.
849 		 *
850 		 * @param exceptionClass the expected exception type
851 		 * @return this builder for method chaining
852 		 */
853 		@SuppressWarnings("unchecked")
854 		public B expectThrow(Class<? extends Throwable> exceptionClass) {
855 			this.expectedException = exceptionClass;
856 			return (B) this;
857 		}
858 
859 		/**
860 		 * Marks the test as requiring POSIX behaviour. The test is skipped when
861 		 * running on a non-POSIX platform.
862 		 *
863 		 * @return this builder for method chaining
864 		 */
865 		@SuppressWarnings("unchecked")
866 		public B posixOnly() {
867 			this.requiresPosix = true;
868 			return (B) this;
869 		}
870 
871 		/**
872 		 * Indicates that the test expects a dedicated temporary directory. The
873 		 * directory path can be referenced using the {@code {{TEMPDIR}}}
874 		 * placeholder.
875 		 *
876 		 * @return this builder for method chaining
877 		 */
878 		@SuppressWarnings("unchecked")
879 		public B withTempDir() {
880 			this.useTempDir = true;
881 			return (B) this;
882 		}
883 
884 		/**
885 		 * Produces an immutable {@link ConfiguredTest} based on the recorded
886 		 * configuration.
887 		 *
888 		 * @return a configured test ready for execution
889 		 */
890 		public ConfiguredTest build() {
891 			TestLayout layout = new TestLayout(
892 					description,
893 					script,
894 					stdin,
895 					postProcessors,
896 					expectedOutput,
897 					expectedLines,
898 					expectedExitCode,
899 					expectedException);
900 			Map<String, String> files = new LinkedHashMap<>(fileContents);
901 			Map<String, String> symlinks = new LinkedHashMap<>(symbolicLinks);
902 			List<String> operands = new ArrayList<>(operandSpecs);
903 			List<String> placeholders = new ArrayList<>(pathPlaceholders);
904 			return buildTestCase(layout, files, symlinks, operands, placeholders);
905 		}
906 
907 		/**
908 		 * Executes the configured test and returns the captured result without
909 		 * asserting it.
910 		 *
911 		 * @return the captured result
912 		 * @throws Exception when execution fails unexpectedly
913 		 */
914 		public TestResult run() throws Exception {
915 			return build().run();
916 		}
917 
918 		/**
919 		 * Executes the configured test and immediately asserts the recorded
920 		 * expectations.
921 		 *
922 		 * @throws Exception when execution fails unexpectedly
923 		 */
924 		public void runAndAssert() throws Exception {
925 			build().runAndAssert();
926 		}
927 
928 		protected abstract BaseTestCase buildTestCase(
929 				TestLayout layout,
930 				Map<String, String> fileContents,
931 				Map<String, String> symbolicLinks,
932 				List<String> operandSpecs,
933 				List<String> pathPlaceholders);
934 	}
935 
936 	private abstract static class BaseTestCase implements ConfiguredTest {
937 		private final TestLayout layout;
938 		private final Map<String, String> fileContents;
939 		private final Map<String, String> symbolicLinks;
940 		private final List<String> operandSpecs;
941 		private final List<String> pathPlaceholders;
942 		private final boolean requiresPosix;
943 
944 		BaseTestCase(
945 				TestLayout layout,
946 				Map<String, String> fileContents,
947 				Map<String, String> symbolicLinks,
948 				List<String> operandSpecs,
949 				List<String> pathPlaceholders,
950 				boolean requiresPosix) {
951 			this.layout = layout;
952 			this.fileContents = fileContents;
953 			this.symbolicLinks = symbolicLinks;
954 			this.operandSpecs = operandSpecs;
955 			this.pathPlaceholders = pathPlaceholders;
956 			this.requiresPosix = requiresPosix;
957 		}
958 
959 		@Override
960 		public String description() {
961 			return layout.description;
962 		}
963 
964 		@Override
965 		public void assumeSupported() {
966 			if (requiresPosix) {
967 				assumeTrue("POSIX-like environment required for " + layout.description, IS_POSIX);
968 			}
969 		}
970 
971 		@Override
972 		public final TestResult run() throws Exception {
973 			assumeSupported();
974 			ExecutionEnvironment env = prepareEnvironment();
975 			try {
976 				return executeAndCapture(env);
977 			} finally {
978 				deleteRecursively(env.tempDir);
979 			}
980 		}
981 
982 		private TestResult executeAndCapture(ExecutionEnvironment env) throws Exception {
983 			try {
984 				// Execute AWK and get the output
985 				ActualResult result = execute(env);
986 				String actualOutput = result.output;
987 
988 				// Post-processing of the output
989 				if (layout.postProcessors != null) {
990 					for (Function<String, String> processor : layout.postProcessors) {
991 						actualOutput = processor.apply(actualOutput);
992 					}
993 				}
994 
995 				// Post-processing of the expected result (resolve temporary paths)
996 				String expected = layout.expectedOutput != null ? env.resolve(layout.expectedOutput) : null;
997 				List<String> expectedLines = null;
998 				if (layout.expectedLines != null) {
999 					expectedLines = new ArrayList<>(layout.expectedLines.size());
1000 					for (String line : layout.expectedLines) {
1001 						expectedLines.add(env.resolve(line));
1002 					}
1003 				}
1004 
1005 				return new TestResult(
1006 						layout.description,
1007 						actualOutput,
1008 						result.errorOutput,
1009 						result.exitCode,
1010 						expected,
1011 						expectedLines,
1012 						layout.expectedExitCode,
1013 						layout.expectedException,
1014 						null);
1015 			} catch (Throwable ex) {
1016 				if (layout.expectedException != null && layout.expectedException.isInstance(ex)) {
1017 					return new TestResult(
1018 							layout.description,
1019 							"",
1020 							"",
1021 							0,
1022 							null,
1023 							null,
1024 							layout.expectedExitCode,
1025 							layout.expectedException,
1026 							ex);
1027 				}
1028 				if (ex instanceof Exception) {
1029 					throw (Exception) ex;
1030 				}
1031 				throw (Error) ex;
1032 			}
1033 		}
1034 
1035 		protected abstract ActualResult execute(ExecutionEnvironment env) throws Exception;
1036 
1037 		protected ExecutionEnvironment prepareEnvironment() throws IOException {
1038 			Path tempDir = Files.createTempDirectory("jawk-test");
1039 			Map<String, Path> placeholders = new LinkedHashMap<>();
1040 			for (Map.Entry<String, String> entry : fileContents.entrySet()) {
1041 				Path path = tempDir.resolve(entry.getKey());
1042 				Path parent = path.getParent();
1043 				if (parent != null) {
1044 					Files.createDirectories(parent);
1045 				}
1046 
1047 				if (entry.getValue() != null) {
1048 					try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
1049 						writer.write(entry.getValue());
1050 					}
1051 				}
1052 				placeholders.put(entry.getKey(), path);
1053 			}
1054 			for (Map.Entry<String, String> entry : symbolicLinks.entrySet()) {
1055 				Path link = tempDir.resolve(entry.getKey());
1056 				Path parent = link.getParent();
1057 				if (parent != null) {
1058 					Files.createDirectories(parent);
1059 				}
1060 				try {
1061 					Files.createSymbolicLink(link, tempDir.resolve(entry.getValue()));
1062 				} catch (IOException | UnsupportedOperationException | SecurityException ex) {
1063 					deleteRecursively(tempDir);
1064 					assumeNoException("Symbolic links are unavailable for " + layout.description, ex);
1065 				}
1066 				placeholders.put(entry.getKey(), link);
1067 			}
1068 			for (String placeholder : pathPlaceholders) {
1069 				Path path = tempDir.resolve(placeholder);
1070 				Path parent = path.getParent();
1071 				if (parent != null) {
1072 					Files.createDirectories(parent);
1073 				}
1074 				placeholders.put(placeholder, path);
1075 			}
1076 			return new ExecutionEnvironment(tempDir, placeholders);
1077 		}
1078 
1079 		protected List<String> resolvedOperands(ExecutionEnvironment env) {
1080 			return operandSpecs
1081 					.stream()
1082 					.map(env::resolve)
1083 					.collect(Collectors.toList());
1084 		}
1085 
1086 		protected String resolvedScript(ExecutionEnvironment env) {
1087 			return layout.script != null ? env.resolveScript(layout.script) : null;
1088 		}
1089 
1090 		protected String resolvedStdin(ExecutionEnvironment env) {
1091 			return layout.stdin != null ? env.resolve(layout.stdin) : null;
1092 		}
1093 	}
1094 
1095 	private static final class AwkTestCase extends BaseTestCase {
1096 		private final Map<String, Object> preAssignments;
1097 		private final Awk customAwk;
1098 		private final List<JawkExtension> extensions;
1099 		private final InputSource inputSource;
1100 		private final Reader scriptReader;
1101 		private final Path scriptPath;
1102 
1103 		AwkTestCase(
1104 				TestLayout layout,
1105 				Map<String, String> fileContents,
1106 				Map<String, String> symbolicLinks,
1107 				List<String> operandSpecs,
1108 				List<String> pathPlaceholders,
1109 				boolean requiresPosix,
1110 				Map<String, Object> preAssignments,
1111 				Awk customAwk,
1112 				List<JawkExtension> extensions,
1113 				InputSource inputSource,
1114 				Reader scriptReader,
1115 				Path scriptPath) {
1116 			super(layout, fileContents, symbolicLinks, operandSpecs, pathPlaceholders, requiresPosix);
1117 			this.preAssignments = new LinkedHashMap<>(preAssignments);
1118 			this.customAwk = customAwk;
1119 			this.extensions = new ArrayList<>(extensions);
1120 			this.inputSource = inputSource;
1121 			this.scriptReader = scriptReader;
1122 			this.scriptPath = scriptPath;
1123 		}
1124 
1125 		@Override
1126 		protected ActualResult execute(ExecutionEnvironment env) throws Exception {
1127 			// Mirror the CLI: no explicit extensions means the default set (which
1128 			// includes the gawk builtins), while an explicit list is honored as-is.
1129 			Awk awk;
1130 			if (customAwk != null) {
1131 				awk = customAwk;
1132 			} else if (extensions.isEmpty()) {
1133 				awk = new Awk();
1134 			} else {
1135 				awk = new Awk(extensions);
1136 			}
1137 			StringBuilder out = new StringBuilder();
1138 			AwkProgram program;
1139 			if (scriptPath != null) {
1140 				try (BufferedReader reader = Files.newBufferedReader(scriptPath, StandardCharsets.UTF_8)) {
1141 					program = awk.compile(reader);
1142 				}
1143 			} else if (scriptReader != null) {
1144 				try (Reader reader = scriptReader) {
1145 					program = awk.compile(reader);
1146 				}
1147 			} else {
1148 				program = awk.compile(resolvedScript(env));
1149 			}
1150 			Awk.AwkRunBuilder builder = awk
1151 					.script(program)
1152 					.arguments(resolvedOperands(env))
1153 					.variables(preAssignments);
1154 			if (inputSource != null) {
1155 				builder.input(inputSource);
1156 			} else {
1157 				String stdin = resolvedStdin(env);
1158 				if (stdin != null) {
1159 					builder.input(stdin);
1160 				}
1161 			}
1162 			int exitCode = 0;
1163 			try {
1164 				builder.execute(out);
1165 			} catch (ExitException ex) {
1166 				exitCode = ex.getCode();
1167 			}
1168 			return new ActualResult(out.toString(), "", exitCode);
1169 		}
1170 	}
1171 
1172 	private static final class CliTestCase extends BaseTestCase {
1173 		private final List<String> argumentSpecs;
1174 		private final Map<String, Object> assignments;
1175 		private final Map<String, String> environment;
1176 		private final boolean redirectErrorStream;
1177 		private final InputStream stdinStream;
1178 
1179 		CliTestCase(
1180 				TestLayout layout,
1181 				Map<String, String> fileContents,
1182 				Map<String, String> symbolicLinks,
1183 				List<String> operandSpecs,
1184 				List<String> pathPlaceholders,
1185 				boolean requiresPosix,
1186 				List<String> argumentSpecs,
1187 				Map<String, Object> assignments,
1188 				Map<String, String> environment,
1189 				boolean redirectErrorStream,
1190 				InputStream stdinStream) {
1191 			super(layout, fileContents, symbolicLinks, operandSpecs, pathPlaceholders, requiresPosix);
1192 			this.argumentSpecs = new ArrayList<>(argumentSpecs);
1193 			this.assignments = new LinkedHashMap<>(assignments);
1194 			this.environment = new LinkedHashMap<>(environment);
1195 			this.redirectErrorStream = redirectErrorStream;
1196 			this.stdinStream = stdinStream;
1197 		}
1198 
1199 		@Override
1200 		protected ActualResult execute(ExecutionEnvironment env) throws Exception {
1201 			String stdin = resolvedStdin(env);
1202 			InputStream in;
1203 			if (stdinStream != null) {
1204 				in = stdinStream;
1205 			} else {
1206 				in = stdin != null ?
1207 						new ByteArrayInputStream(stdin.getBytes(StandardCharsets.UTF_8)) :
1208 						new ByteArrayInputStream(new byte[0]);
1209 			}
1210 			ByteArrayOutputStream outBytes = new ByteArrayOutputStream();
1211 			ByteArrayOutputStream errBytes = new ByteArrayOutputStream();
1212 			Map<String, String> resolvedEnvironment = new LinkedHashMap<String, String>();
1213 			for (Map.Entry<String, String> entry : environment.entrySet()) {
1214 				resolvedEnvironment.put(entry.getKey(), env.resolve(entry.getValue()));
1215 			}
1216 			PrintStream outStream = new PrintStream(outBytes, true, StandardCharsets.UTF_8.name());
1217 			PrintStream errStream = redirectErrorStream ?
1218 					outStream :
1219 					new PrintStream(errBytes, true, StandardCharsets.UTF_8.name());
1220 			Cli cli = new Cli(
1221 					in,
1222 					outStream,
1223 					errStream,
1224 					resolvedEnvironment);
1225 
1226 			List<String> args = new ArrayList<>();
1227 			for (Map.Entry<String, Object> entry : assignments.entrySet()) {
1228 				args.add("-v");
1229 				args.add(entry.getKey() + "=" + String.valueOf(entry.getValue()));
1230 			}
1231 			for (String spec : argumentSpecs) {
1232 				args.add(env.resolve(spec));
1233 			}
1234 			String resolvedScript = resolvedScript(env);
1235 			if (resolvedScript != null) {
1236 				args.add(resolvedScript);
1237 			}
1238 			args.addAll(resolvedOperands(env));
1239 
1240 			int exitCode = 0;
1241 			try {
1242 				cli.parse(args.toArray(new String[0]));
1243 				cli.run();
1244 			} catch (ExitException ex) {
1245 				exitCode = ex.getCode();
1246 			}
1247 			return new ActualResult(
1248 					outBytes.toString(StandardCharsets.UTF_8.name()),
1249 					errBytes.toString(StandardCharsets.UTF_8.name()),
1250 					exitCode);
1251 		}
1252 	}
1253 
1254 	private static final class ExecutionEnvironment {
1255 		private final Path tempDir;
1256 		private final Map<String, Path> placeholders;
1257 
1258 		ExecutionEnvironment(Path tempDir, Map<String, Path> placeholders) {
1259 			this.tempDir = tempDir;
1260 			this.placeholders = placeholders;
1261 		}
1262 
1263 		String resolve(String value) {
1264 			if (value == null) {
1265 				return null;
1266 			}
1267 			return replacePlaceholders(value, false);
1268 		}
1269 
1270 		String resolveScript(String value) {
1271 			if (value == null) {
1272 				return null;
1273 			}
1274 			return replacePlaceholders(value, true);
1275 		}
1276 
1277 		private String replacePlaceholders(String value, boolean escapeForScript) {
1278 			String result = value;
1279 			for (Map.Entry<String, Path> entry : placeholders.entrySet()) {
1280 				String replacement = entry.getValue().toString();
1281 				if (escapeForScript) {
1282 					replacement = escapeForAwkString(replacement);
1283 				}
1284 				result = result.replace("{{" + entry.getKey() + "}}", replacement);
1285 			}
1286 			return result;
1287 		}
1288 	}
1289 
1290 	private static final class ActualResult {
1291 		final String output;
1292 		final String errorOutput;
1293 		final int exitCode;
1294 
1295 		ActualResult(String output, String errorOutput, int exitCode) {
1296 			this.output = output;
1297 			this.errorOutput = errorOutput;
1298 			this.exitCode = exitCode;
1299 		}
1300 	}
1301 
1302 	private static final class TestLayout {
1303 		final String description;
1304 		final String script;
1305 		final String stdin;
1306 		final List<Function<String, String>> postProcessors;
1307 		final String expectedOutput;
1308 		final List<String> expectedLines;
1309 		final Integer expectedExitCode;
1310 		final Class<? extends Throwable> expectedException;
1311 
1312 		TestLayout(
1313 				String description,
1314 				String script,
1315 				String stdin,
1316 				List<Function<String, String>> postProcessors,
1317 				String expectedOutput,
1318 				List<String> expectedLines,
1319 				Integer expectedExitCode,
1320 				Class<? extends Throwable> expectedException) {
1321 			this.description = description;
1322 			this.script = script;
1323 			this.stdin = stdin;
1324 			this.postProcessors = postProcessors != null ?
1325 					Collections.unmodifiableList(new ArrayList<>(postProcessors)) : null;
1326 			this.expectedOutput = expectedOutput;
1327 			this.expectedLines = expectedLines != null ? Collections.unmodifiableList(new ArrayList<>(expectedLines)) : null;
1328 			this.expectedExitCode = expectedExitCode;
1329 			this.expectedException = expectedException;
1330 		}
1331 	}
1332 
1333 	private static void deleteRecursively(Path root) throws IOException {
1334 		if (root == null || !Files.exists(root)) {
1335 			return;
1336 		}
1337 		try (Stream<Path> walk = Files.walk(root)) {
1338 			walk.sorted((a, b) -> b.compareTo(a)).forEach(path -> {
1339 				try {
1340 					Files.deleteIfExists(path);
1341 				} catch (IOException ignored) {
1342 					// best effort cleanup
1343 				}
1344 			});
1345 		}
1346 	}
1347 
1348 	private static String escapeForAwkString(String value) {
1349 		StringBuilder builder = new StringBuilder(value.length() * 2);
1350 		for (int i = 0; i < value.length(); i++) {
1351 			char ch = value.charAt(i);
1352 			if (ch == '\\' || ch == '"') {
1353 				builder.append('\\');
1354 			}
1355 			builder.append(ch);
1356 		}
1357 		return builder.toString();
1358 	}
1359 }