View Javadoc
1   package io.jawk.jrt;
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 java.io.Closeable;
26  import java.io.File;
27  import java.io.FileInputStream;
28  import java.io.IOException;
29  import java.io.InputStream;
30  import java.io.InputStreamReader;
31  import java.nio.charset.StandardCharsets;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.Objects;
35  
36  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
37  
38  /**
39   * An {@link InputSource} that reads records from an {@link InputStream},
40   * traversing the {@code ARGV} array to open filenames and apply
41   * {@code name=value} variable assignments exactly like the classic AWK
42   * command-line flow.
43   * <p>
44   * When no filename arguments are present in {@code ARGV}, records are read
45   * from the supplied default {@link InputStream} (usually {@code System.in}).
46   * This class is the default {@link InputSource} used internally by the
47   * runtime when no custom source has been configured via
48   * {@code AwkSettings#setInputSource(...)}.
49   * </p>
50   * <p>
51   * API note: this type is public to allow runtime wiring between packages, but
52   * it is considered an internal implementation detail. Embedding applications
53   * should implement {@link InputSource} directly rather than depend on this
54   * class, whose behavior may change in future releases.
55   * </p>
56   *
57   * @see InputSource
58   */
59  public class StreamInputSource implements InputSource, Closeable {
60  
61  	private final InputStream defaultInput;
62  	private final VariableManager vm;
63  	private final JRT jrt;
64  
65  	// ARGV traversal state
66  	private Map<Object, Object> arglistMap;
67  	private int arglistIdx;
68  	private int arglistMaxKey;
69  	private boolean hasFilenames;
70  
71  	// Current reader and record
72  	private PartitioningReader partitioningReader;
73  	private boolean currentReaderIsDefaultInput;
74  	private boolean currentFromFilenameList;
75  	private String currentRecord;
76  	private boolean currentReaderExhausted;
77  
78  	// Per-file stepping state (BEGINFILE/ENDFILE and nextfile support)
79  	private String currentFileOpenError;
80  	private boolean currentPresentedToLoop;
81  	private int lastArgumentIndex;
82  
83  	/**
84  	 * Creates a stream-backed input source.
85  	 *
86  	 * @param defaultInput the fallback input stream used when {@code ARGV}
87  	 *        contains no filename arguments (typically {@code System.in})
88  	 * @param vm the variable manager providing access to {@code ARGV} and
89  	 *        {@code ARGC}
90  	 * @param jrt the JRT instance used for string conversion and special
91  	 *        variable updates
92  	 */
93  	@SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Fail-fast argument validation; no security-sensitive state to protect from finalizer attacks")
94  	public StreamInputSource(InputStream defaultInput, VariableManager vm, JRT jrt) {
95  		this.defaultInput = Objects.requireNonNull(defaultInput, "defaultInput");
96  		this.vm = Objects.requireNonNull(vm, "vm");
97  		this.jrt = Objects.requireNonNull(jrt, "jrt");
98  	}
99  
100 	/**
101 	 * Returns the stream this source falls back to when {@code ARGV} holds no
102 	 * filename. It is what the run treats as its standard input, both for the
103 	 * conventional {@code -} filename and for the gawk special filename
104 	 * {@code /dev/stdin}, which the runtime binds to it when this source becomes
105 	 * the active one.
106 	 *
107 	 * @return the default input stream
108 	 */
109 	InputStream getDefaultInput() {
110 		return defaultInput;
111 	}
112 
113 	/** {@inheritDoc} */
114 	@Override
115 	public boolean nextRecord() throws IOException {
116 		initializeArgList();
117 
118 		while (true) {
119 			if (partitioningReader == null || currentReaderExhausted) {
120 				if (!prepareNextReader()) {
121 					return false;
122 				}
123 				currentReaderExhausted = false;
124 			}
125 
126 			String nextRecord = partitioningReader.readRecord();
127 			if (nextRecord != null) {
128 				currentRecord = nextRecord;
129 				currentFromFilenameList = partitioningReader.fromFilenameList();
130 				return true;
131 			}
132 			if (!partitioningReader.fromFilenameList()) {
133 				return false;
134 			}
135 			currentReaderExhausted = true;
136 		}
137 	}
138 
139 	/** {@inheritDoc} */
140 	@Override
141 	public String getRecordText() {
142 		return currentRecord;
143 	}
144 
145 	/**
146 	 * Always returns {@code null} so that the runtime splits {@code $0} using
147 	 * the current field separator (FS).
148 	 *
149 	 * @return {@code null}
150 	 */
151 	@Override
152 	public List<String> getFields() {
153 		return null;
154 	}
155 
156 	/** {@inheritDoc} */
157 	@Override
158 	public boolean isFromFilenameList() {
159 		return currentFromFilenameList;
160 	}
161 
162 	/**
163 	 * Propagates a record-separator change to the active
164 	 * {@link PartitioningReader}.
165 	 *
166 	 * @param rs the new record separator value
167 	 */
168 	public void setRecordSeparator(String rs) {
169 		if (partitioningReader != null) {
170 			partitioningReader.setRecordSeparator(rs);
171 		}
172 	}
173 
174 	/**
175 	 * Returns the underlying {@link PartitioningReader} currently in use, or
176 	 * {@code null} if no reader has been opened yet.
177 	 *
178 	 * @return the active reader, or {@code null}
179 	 */
180 	PartitioningReader getPartitioningReader() {
181 		return partitioningReader;
182 	}
183 
184 	// ------------------------------------------------------------------
185 	// ARGV traversal logic (moved from JRT)
186 	// ------------------------------------------------------------------
187 
188 	/**
189 	 * Initialize internal state for traversing {@code ARGV}.
190 	 */
191 	private void initializeArgList() {
192 		if (arglistMap != null) {
193 			return;
194 		}
195 		arglistMap = toArgvMap(vm.getARGV());
196 		arglistMaxKey = computeMaxArgvKey();
197 		arglistIdx = 1;
198 		hasFilenames = detectFilenames();
199 	}
200 
201 	private Map<Object, Object> toArgvMap(Object argv) {
202 		if (!(argv instanceof Map)) {
203 			throw new IllegalArgumentException("ARGV must be a Map.");
204 		}
205 		@SuppressWarnings("unchecked")
206 		Map<Object, Object> argvMap = (Map<Object, Object>) argv;
207 		return argvMap;
208 	}
209 
210 	/**
211 	 * Compute the highest numeric key present in the current {@code arglistMap}.
212 	 *
213 	 * @return the maximum integer key, or {@code 0} when the array is empty
214 	 */
215 	private int computeMaxArgvKey() {
216 		int max = 0;
217 		for (Object key : arglistMap.keySet()) {
218 			int idx = (int) JRT.toLong(key);
219 			if (idx > max) {
220 				max = idx;
221 			}
222 		}
223 		return max;
224 	}
225 
226 	/**
227 	 * Determine whether {@code ARGV} contains any filename entries (arguments
228 	 * without an equals sign).
229 	 *
230 	 * @return {@code true} if at least one filename was found
231 	 */
232 	private boolean detectFilenames() {
233 		int traversalArgCount = getTraversalArgCount();
234 		boolean found = false;
235 		for (int i = 1; i < traversalArgCount && !found; i++) {
236 			Object argValue = getArgvValue(i);
237 			if (argValue == MISSING_ARGV_VALUE) {
238 				continue;
239 			}
240 			String arg = jrt.toAwkString(argValue);
241 			if (arg.isEmpty() || arg.indexOf('=') > 0) {
242 				continue;
243 			}
244 			found = true;
245 		}
246 		return found;
247 	}
248 
249 	/**
250 	 * Retrieve the number of command-line arguments supplied to the script.
251 	 *
252 	 * @return {@code ARGC} converted to an {@code int}
253 	 */
254 	private int getArgCount() {
255 		double raw = JRT.toDouble(vm.getARGC());
256 		if (raw <= 0) {
257 			return 0;
258 		}
259 		if (raw > Integer.MAX_VALUE) {
260 			return Integer.MAX_VALUE;
261 		}
262 		return (int) raw;
263 	}
264 
265 	/**
266 	 * Return the effective upper bound for ARGV traversal, capped by the
267 	 * highest known ARGV key so that absurdly large ARGC values do not
268 	 * cause unbounded iteration over missing entries.
269 	 *
270 	 * @return the capped traversal count
271 	 */
272 	private int getTraversalArgCount() {
273 		int argCount = getArgCount();
274 		if (argCount <= 0) {
275 			return 0;
276 		}
277 		return Math.min(argCount, arglistMaxKey + 1);
278 	}
279 
280 	/**
281 	 * Obtain the next valid argument from {@code ARGV}, skipping
282 	 * uninitialized or empty entries.
283 	 *
284 	 * @return the next argument as an AWK string, or {@code null} if none
285 	 *         remain
286 	 */
287 	private String nextArgument() {
288 		int traversalArgCount = getTraversalArgCount();
289 		while (arglistIdx < traversalArgCount) {
290 			int idx = arglistIdx++;
291 			Object argValue = getArgvValue(idx);
292 			if (argValue == MISSING_ARGV_VALUE) {
293 				continue;
294 			}
295 			String arg = jrt.toAwkString(argValue);
296 			if (!arg.isEmpty()) {
297 				lastArgumentIndex = idx;
298 				return arg;
299 			}
300 		}
301 		return null;
302 	}
303 
304 	private static final Object MISSING_ARGV_VALUE = new Object();
305 
306 	private Object getArgvValue(int index) {
307 		Long longIndex = Long.valueOf(index);
308 		if (arglistMap instanceof AssocArray) {
309 			return JRT.containsAwkKey(arglistMap, longIndex) ?
310 					JRT.getAssocArrayValue(arglistMap, longIndex) : MISSING_ARGV_VALUE;
311 		}
312 		if (arglistMap.containsKey(longIndex)) {
313 			return arglistMap.get(longIndex);
314 		}
315 		Integer intIndex = Integer.valueOf(index);
316 		if (arglistMap.containsKey(intIndex)) {
317 			return arglistMap.get(intIndex);
318 		}
319 		for (Map.Entry<Object, Object> entry : arglistMap.entrySet()) {
320 			Object key = entry.getKey();
321 			if (!(key instanceof Number)) {
322 				continue;
323 			}
324 			double numericKey = ((Number) key).doubleValue();
325 			if (JRT.isActuallyLong(numericKey) && ((long) Math.rint(numericKey)) == index) {
326 				return entry.getValue();
327 			}
328 		}
329 		return MISSING_ARGV_VALUE;
330 	}
331 
332 	/**
333 	 * Prepare the {@link PartitioningReader} for the next input source. This
334 	 * may be a filename, a variable assignment, or standard input if no
335 	 * filenames remain.
336 	 *
337 	 * @return {@code true} if a reader was prepared, {@code false} if no more
338 	 *         input is available
339 	 * @throws IOException if an I/O error occurs while opening a file
340 	 */
341 	private boolean prepareNextReader() throws IOException {
342 		boolean ready = false;
343 		arglistMaxKey = computeMaxArgvKey();
344 		hasFilenames = detectFilenames();
345 		while (!ready) {
346 			String arg = nextArgument();
347 			if (arg == null) {
348 				// ARGC/ARGV may have changed while evaluating assignments.
349 				hasFilenames = detectFilenames();
350 				if (partitioningReader == null && !hasFilenames) {
351 					partitioningReader = new PartitioningReader(
352 							new InputStreamReader(defaultInput, StandardCharsets.UTF_8),
353 							jrt.getRSString());
354 					currentReaderIsDefaultInput = true;
355 					jrt.setFILENAMEViaJrt(jrt.toInputScalar(""));
356 					// gawk clears ERRNO whenever the main input advances
357 					// successfully
358 					jrt.setERRNO("");
359 					return true;
360 				}
361 				closeCurrentReaderIfFileStream();
362 				return false;
363 			}
364 			if (arg.indexOf('=') > 0) {
365 				setFilelistVariable(arg);
366 				// Recompute bounds so ARGC changes are reflected immediately.
367 				arglistMaxKey = computeMaxArgvKey();
368 				hasFilenames = detectFilenames();
369 				if (partitioningReader == null && !hasFilenames) {
370 					partitioningReader = new PartitioningReader(
371 							new InputStreamReader(defaultInput, StandardCharsets.UTF_8),
372 							jrt.getRSString());
373 					currentReaderIsDefaultInput = true;
374 					jrt.setFILENAMEViaJrt(jrt.toInputScalar(""));
375 					// gawk clears ERRNO whenever the main input advances
376 					// successfully
377 					jrt.setERRNO("");
378 					return true;
379 				}
380 			} else {
381 				closeCurrentReaderIfFileStream();
382 				partitioningReader = openFileListReader(arg);
383 				jrt.setFILENAMEViaJrt(jrt.toInputScalar(arg));
384 				jrt.setFNR(0L);
385 				jrt.setARGIND(Long.valueOf(lastArgumentIndex));
386 				// gawk clears ERRNO whenever the main input advances
387 				// successfully
388 				jrt.setERRNO("");
389 				ready = true;
390 			}
391 		}
392 		return true;
393 	}
394 
395 	/**
396 	 * Advance to the next input file for the per-file main input loop used
397 	 * when BEGINFILE/ENDFILE rules or {@code nextfile} are present. Variable
398 	 * assignment arguments are applied along the way, exactly like
399 	 * {@link #nextRecord()} does when it crosses a file boundary.
400 	 * <p>
401 	 * On success, FILENAME, FNR, ARGIND, and ERRNO are updated and {@code $0}
402 	 * is cleared, so the BEGINFILE rules observe the new file before any
403 	 * record is read. A file that cannot be opened is still reported as
404 	 * available: ERRNO carries the error description and
405 	 * {@link #getCurrentFileOpenError()} returns it until the next advance,
406 	 * enabling gawk's non-fatal BEGINFILE error handling.
407 	 * </p>
408 	 *
409 	 * @return {@code true} when a new input file (or the initial stdin
410 	 *         stream) is current; {@code false} when input is exhausted
411 	 * @throws IOException if an I/O error occurs while traversing ARGV
412 	 */
413 	public boolean advanceToNextFile() throws IOException {
414 		initializeArgList();
415 
416 		// Adopt a reader already opened by a non-redirected getline that ran
417 		// before the per-file loop (e.g. in a BEGIN rule): it is the current
418 		// input file, already positioned after the records getline consumed.
419 		if (!currentPresentedToLoop
420 				&& partitioningReader != null
421 				&& !currentReaderExhausted
422 				&& currentFileOpenError == null) {
423 			currentPresentedToLoop = true;
424 			return true;
425 		}
426 
427 		currentFileOpenError = null;
428 		arglistMaxKey = computeMaxArgvKey();
429 		hasFilenames = detectFilenames();
430 		while (true) {
431 			String arg = nextArgument();
432 			if (arg == null) {
433 				// ARGC/ARGV may have changed while evaluating assignments.
434 				hasFilenames = detectFilenames();
435 				if (partitioningReader == null && !hasFilenames) {
436 					return presentDefaultInput();
437 				}
438 				closeCurrentReaderIfFileStream();
439 				return false;
440 			}
441 			if (arg.indexOf('=') > 0) {
442 				setFilelistVariable(arg);
443 				// Recompute bounds so ARGC changes are reflected immediately.
444 				arglistMaxKey = computeMaxArgvKey();
445 				hasFilenames = detectFilenames();
446 				if (partitioningReader == null && !hasFilenames) {
447 					return presentDefaultInput();
448 				}
449 			} else {
450 				closeCurrentReaderIfFileStream();
451 				partitioningReader = null;
452 				currentReaderExhausted = false;
453 				currentPresentedToLoop = true;
454 				jrt.setFILENAMEViaJrt(jrt.toInputScalar(arg));
455 				beginFileState(lastArgumentIndex);
456 				currentFileOpenError = openCurrentFile(arg);
457 				if (currentFileOpenError != null) {
458 					jrt.setERRNO(currentFileOpenError);
459 				}
460 				return true;
461 			}
462 		}
463 	}
464 
465 	/**
466 	 * Reads the next record of the current input file only, never advancing
467 	 * to the next input file. Used by the per-file main input loop so that
468 	 * ENDFILE rules can run at each file boundary.
469 	 *
470 	 * @return {@code true} when a record is available; {@code false} at the
471 	 *         end of the current input file
472 	 * @throws IOException if an I/O error occurs
473 	 */
474 	public boolean nextRecordInCurrentFile() throws IOException {
475 		if (partitioningReader == null || currentReaderExhausted || currentFileOpenError != null) {
476 			return false;
477 		}
478 		String nextRecord = partitioningReader.readRecord();
479 		if (nextRecord == null) {
480 			currentReaderExhausted = true;
481 			return false;
482 		}
483 		currentRecord = nextRecord;
484 		currentFromFilenameList = partitioningReader.fromFilenameList();
485 		return true;
486 	}
487 
488 	/**
489 	 * Returns the error description recorded when the current input file
490 	 * could not be opened by {@link #advanceToNextFile()}, or {@code null}
491 	 * when the current input is readable.
492 	 *
493 	 * @return the pending open error, or {@code null}
494 	 */
495 	public String getCurrentFileOpenError() {
496 		return currentFileOpenError;
497 	}
498 
499 	/**
500 	 * Presents the default input stream (usually stdin) as the current and
501 	 * only input "file" for the per-file main input loop.
502 	 *
503 	 * @return always {@code true}
504 	 */
505 	private boolean presentDefaultInput() {
506 		partitioningReader = new PartitioningReader(
507 				new InputStreamReader(defaultInput, StandardCharsets.UTF_8),
508 				jrt.getRSString());
509 		currentReaderIsDefaultInput = true;
510 		currentPresentedToLoop = true;
511 		jrt.setFILENAMEViaJrt(jrt.toInputScalar(""));
512 		beginFileState(0);
513 		return true;
514 	}
515 
516 	/**
517 	 * Resets the per-file special variables observed by BEGINFILE rules: FNR
518 	 * and {@code $0} are cleared, ERRNO is emptied, and ARGIND designates the
519 	 * ARGV entry being processed.
520 	 *
521 	 * @param argvIndex the ARGV index of the new current file, or {@code 0}
522 	 *        for the default input stream
523 	 */
524 	private void beginFileState(int argvIndex) {
525 		jrt.setFNR(0L);
526 		jrt.setERRNO("");
527 		jrt.setARGIND(Long.valueOf(argvIndex));
528 		jrt.setInputLine("");
529 	}
530 
531 	/**
532 	 * Attempts to open the given filename as the current input file.
533 	 *
534 	 * @param arg the filename to open
535 	 * @return {@code null} on success, or a gawk-style error description when
536 	 *         the file cannot be opened for reading
537 	 */
538 	private String openCurrentFile(String arg) {
539 		// The default input stream and the null device are not files to stat:
540 		// Windows reports its null device as nonexistent, under either spelling,
541 		// so the checks below would turn an operand that reads as an empty file
542 		// into an open error — while the plain main input loop, which opens the
543 		// operand without stat'ing it first, reads it happily.
544 		if ("-".equals(arg) || JRT.isNullDeviceName(arg)) {
545 			try {
546 				partitioningReader = openFileListReader(arg);
547 				return null;
548 			} catch (IOException e) {
549 				return e.getMessage();
550 			}
551 		}
552 		File file = new File(arg);
553 		if (file.isDirectory()) {
554 			return "Is a directory";
555 		}
556 		if (!file.exists()) {
557 			return "No such file or directory";
558 		}
559 		try {
560 			partitioningReader = openFileListReader(arg);
561 			return null;
562 		} catch (IOException e) {
563 			return JRT.describeIoReason(e);
564 		}
565 	}
566 
567 	/**
568 	 * Opens a reader for the given {@code ARGV} filename entry. The
569 	 * conventional {@code -} filename designates the default input stream
570 	 * (usually stdin), as required by POSIX; any other name is opened as a
571 	 * regular file, under the name the platform knows it by, so that
572 	 * {@code /dev/null} is the null device on Windows too.
573 	 *
574 	 * @param arg the filename from the {@code ARGV} file list
575 	 * @return a reader presenting the argument as a file-list input
576 	 * @throws IOException if the file cannot be opened for reading
577 	 */
578 	private PartitioningReader openFileListReader(String arg) throws IOException {
579 		boolean isDefaultInput = "-".equals(arg);
580 		// Open the stream before publishing the flag: if the open fails, the
581 		// still-current reader must keep its own classification, so that
582 		// cleanup does not close the caller-provided default input stream.
583 		InputStream stream = isDefaultInput ? defaultInput : new FileInputStream(JRT.toPlatformFileName(arg));
584 		PartitioningReader reader = new PartitioningReader(
585 				new InputStreamReader(stream, StandardCharsets.UTF_8),
586 				jrt.getRSString(),
587 				true);
588 		currentReaderIsDefaultInput = isDefaultInput;
589 		return reader;
590 	}
591 
592 	/**
593 	 * Closes the current {@link PartitioningReader} if it wraps a file stream
594 	 * (not {@code defaultInput}). This prevents file-descriptor leaks when
595 	 * traversing multiple ARGV files.
596 	 */
597 	private void closeCurrentReaderIfFileStream() {
598 		if (partitioningReader != null && partitioningReader.fromFilenameList() && !currentReaderIsDefaultInput) {
599 			try {
600 				partitioningReader.close();
601 			} catch (IOException ignored) {
602 				// Best-effort close; the file is no longer needed.
603 			}
604 		}
605 	}
606 
607 	/**
608 	 * Releases any open file-backed reader held by this source.
609 	 * <p>
610 	 * This method is idempotent and safe to call multiple times. It does
611 	 * <em>not</em> close the default input stream ({@code System.in}).
612 	 * </p>
613 	 *
614 	 * @throws IOException never thrown; signature required by {@link Closeable}
615 	 */
616 	@Override
617 	public void close() throws IOException {
618 		closeCurrentReaderIfFileStream();
619 	}
620 
621 	/**
622 	 * Parse a {@code name=value} argument from the command line and assign it
623 	 * to the corresponding AWK variable.
624 	 *
625 	 * @param nameValue argument in the form {@code name=value}
626 	 */
627 	private void setFilelistVariable(String nameValue) {
628 		int eqIdx = nameValue.indexOf('=');
629 		if (eqIdx == 0) {
630 			throw new IllegalArgumentException(
631 					"Must have a non-blank variable name in a name=value variable assignment argument.");
632 		}
633 		String name = nameValue.substring(0, eqIdx);
634 		if (name.startsWith("awk::")) {
635 			name = name.substring("awk::".length());
636 		}
637 		String value = nameValue.substring(eqIdx + 1);
638 		vm.assignVariable(name, jrt.toInputScalar(value));
639 	}
640 }