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