View Javadoc
1   package io.jawk.ext;
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.File;
26  import java.math.BigInteger;
27  
28  import java.util.ArrayList;
29  import java.util.Arrays;
30  import java.util.Calendar;
31  import java.util.Collection;
32  import java.util.Collections;
33  import java.util.Comparator;
34  import java.util.Date;
35  import java.util.GregorianCalendar;
36  import java.util.HashMap;
37  import java.util.HashSet;
38  import java.util.List;
39  import java.util.Map;
40  import java.util.Set;
41  import java.util.TimeZone;
42  import java.util.regex.Matcher;
43  import java.util.regex.Pattern;
44  
45  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
46  import io.jawk.backend.AVM;
47  import io.jawk.ext.annotations.JawkAssocArray;
48  import io.jawk.ext.annotations.JawkBeforeStart;
49  import io.jawk.ext.annotations.JawkFunction;
50  import io.jawk.ext.annotations.JawkOptional;
51  import io.jawk.ext.annotations.JawkRawValue;
52  import io.jawk.ext.annotations.JawkRegexp;
53  import io.jawk.intermediate.UninitializedObject;
54  import io.jawk.intermediate.UntypedObject;
55  import io.jawk.jrt.IllegalAwkArgumentException;
56  import io.jawk.jrt.JRT;
57  import io.jawk.jrt.StrNum;
58  
59  /**
60   * GNU awk compatibility extension for array sorting and type introspection.
61   */
62  public class GawkExtension extends AbstractExtension implements JawkExtension {
63  
64  	private static final String VAL_TYPE_ASC = "@val_type_asc";
65  
66  	/** Default {@code strftime()} format, as in gawk's C locale. */
67  	private static final String DEFAULT_STRFTIME_FORMAT = "%a %b %e %H:%M:%S %Z %Y";
68  
69  	/** gawk's default field pattern when {@code FPAT} is unset. */
70  	private static final String DEFAULT_FPAT = "[^\\s]+";
71  
72  	/** Default gettext text domain, as in gawk. */
73  	private static final String DEFAULT_TEXTDOMAIN = "messages";
74  
75  	/**
76  	 * Directory reported for text domains never bound with
77  	 * {@code bindtextdomain()}: gawk's conventional compiled-in default. The
78  	 * value is purely informational — Jawk ships no message catalogs and never
79  	 * accesses this path (so it is harmless on Windows too); it only echoes
80  	 * what a typical gawk reports.
81  	 */
82  	private static final String DEFAULT_LOCALE_DIRECTORY = "/usr/share/locale";
83  
84  	/** Locale categories accepted by the gettext functions, as in gawk. */
85  	private static final Set<String> LOCALE_CATEGORIES = Collections
86  			.unmodifiableSet(
87  					new HashSet<String>(
88  							Arrays
89  									.asList(
90  											"LC_ALL",
91  											"LC_COLLATE",
92  											"LC_CTYPE",
93  											"LC_MESSAGES",
94  											"LC_MONETARY",
95  											"LC_NUMERIC",
96  											"LC_TIME")));
97  
98  	/** Interpreter this per-engine extension instance is bound to. */
99  	private AVM avm;
100 
101 	/** Comparison-function names already warned about; created on first use. */
102 	private Set<String> warnedComparators;
103 
104 	/** Per-domain directory bindings established by {@code bindtextdomain()}; created on first use. */
105 	private Map<String, String> textdomainBindings;
106 
107 	/**
108 	 * Creates the gawk compatibility extension.
109 	 * <p>
110 	 * The instance is bound to its interpreter by
111 	 * {@link #initializeGawkVariables(AVM, JRT)}, which the runtime calls before
112 	 * the script starts.
113 	 * </p>
114 	 */
115 	public GawkExtension() {
116 		// The interpreter is supplied by initializeGawkVariables(), not by construction.
117 	}
118 
119 	private static final class SortEntry {
120 		private final Object index;
121 		private final Object value;
122 
123 		private SortEntry(Object indexParam, Object valueParam) {
124 			this.index = indexParam;
125 			this.value = valueParam;
126 		}
127 	}
128 
129 	/** {@inheritDoc} */
130 	@Override
131 	public String getExtensionName() {
132 		return "GawkExtension";
133 	}
134 
135 	/**
136 	 * Installs the {@code PROCINFO["sorted_in"]} traversal order for
137 	 * {@code for-in} loops and binds this per-engine extension instance to its
138 	 * interpreter. SYMTAB and FUNCTAB are populated by the interpreter itself.
139 	 *
140 	 * @param avmParam interpreter about to execute
141 	 * @param jrt runtime associated with {@code avmParam}
142 	 */
143 	@JawkBeforeStart
144 	@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "The extension is a per-engine instance deliberately bound to its interpreter")
145 	public void initializeGawkVariables(AVM avmParam, JRT jrt) {
146 		this.avm = avmParam;
147 		avm.setForInKeyOrder(this::orderForInKeys);
148 	}
149 
150 	/**
151 	 * Sorts an array by value, optionally writing the result to another array.
152 	 *
153 	 * @param source source array
154 	 * @param dest destination array, or {@code null} to sort in place
155 	 * @param how predefined sorting mode, or {@code null} for the default
156 	 * @return number of sorted elements
157 	 */
158 	@JawkFunction("asort")
159 	public Long asort(
160 			@JawkAssocArray Map<Object, Object> source,
161 			@JawkOptional @JawkAssocArray Map<Object, Object> dest,
162 			@JawkOptional Object how) {
163 		return sort(source, dest, how, false);
164 	}
165 
166 	/**
167 	 * Sorts an array by index, optionally writing the result to another array.
168 	 *
169 	 * @param source source array
170 	 * @param dest destination array, or {@code null} to sort in place
171 	 * @param how predefined sorting mode, or {@code null} for the default
172 	 * @return number of sorted elements
173 	 */
174 	@JawkFunction("asorti")
175 	public Long asorti(
176 			@JawkAssocArray Map<Object, Object> source,
177 			@JawkOptional @JawkAssocArray Map<Object, Object> dest,
178 			@JawkOptional Object how) {
179 		return sort(source, dest, how, true);
180 	}
181 
182 	/**
183 	 * Returns the gawk type category for a value.
184 	 *
185 	 * @param value value to inspect
186 	 * @param meta optional metadata destination array
187 	 * @return gawk type name
188 	 */
189 	@JawkFunction("typeof")
190 	public String typeof(@JawkRawValue Object value, @JawkOptional @JawkAssocArray Map<Object, Object> meta) {
191 		if (meta != null) {
192 			meta.clear();
193 			if (value instanceof Map) {
194 				meta.put("array_type", arrayType((Map<?, ?>) value));
195 			}
196 		}
197 		return typeOf(value);
198 	}
199 
200 	/**
201 	 * Returns whether the supplied value is an array.
202 	 *
203 	 * @param value value to inspect
204 	 * @return 1 for arrays, 0 otherwise
205 	 */
206 	@JawkFunction("isarray")
207 	public Long isarray(@JawkRawValue Object value) {
208 		return value instanceof Map ? Long.valueOf(1L) : Long.valueOf(0L);
209 	}
210 
211 	/**
212 	 * Creates a boolean-typed numeric value used by gawk's test suite.
213 	 *
214 	 * @param value truth value
215 	 * @return boolean numeric value
216 	 */
217 	@JawkFunction("mkbool")
218 	public GawkBool mkbool(Object value) {
219 		// gawk applies ordinary AWK truthiness: a non-empty non-numeric
220 		// string like "abc" is true, not numeric-coerced to 0
221 		return new GawkBool(getJrt().toBoolean(value));
222 	}
223 
224 	/**
225 	 * Performs a small gawk-compatible {@code gensub()} substitution.
226 	 *
227 	 * @param regexp regular expression
228 	 * @param replacement replacement text
229 	 * @param how occurrence selector or {@code g}
230 	 * @param target target text, or {@code null} to default to {@code $0}
231 	 * @return substituted text
232 	 */
233 	@JawkFunction("gensub")
234 	public String gensub(@JawkRegexp Object regexp, Object replacement, Object how, @JawkOptional Object target) {
235 		Pattern pattern = regexp instanceof Pattern ?
236 				(Pattern) regexp : Pattern.compile(toAwkString(regexp));
237 		// gawk: a truthy IGNORECASE makes all regexp operations case-insensitive
238 		pattern = getJrt().caseAwarePattern(pattern);
239 		Object targetValue = target == null ? getJrt().getInputLine() : target;
240 		Matcher matcher = pattern.matcher(toAwkString(targetValue));
241 		String repl = JRT.prepareReplacement(toAwkString(replacement), pattern.matcher("").groupCount());
242 		String selector = toAwkString(how);
243 		// gawk: any string beginning with 'g' or 'G' selects a global replacement
244 		if (!selector.isEmpty() && (selector.charAt(0) == 'g' || selector.charAt(0) == 'G')) {
245 			return matcher.replaceAll(repl);
246 		}
247 		// gawk coerces the selector with AWK numeric conversion (so " 2" and
248 		// "1e1" are the occurrences 2 and 10) and warns only when the result,
249 		// truncated, is below 1
250 		double selected = JRT.toDouble(how);
251 		if (selected < 1.0D) {
252 			warnAtCurrentLine("gensub: third argument `%s' treated as 1", selector);
253 			selected = 1.0D;
254 		}
255 		int occurrence = (int) selected;
256 		if (occurrence == 1) {
257 			return matcher.replaceFirst(repl);
258 		}
259 		StringBuffer result = new StringBuffer();
260 		int seen = 0;
261 		while (matcher.find()) {
262 			seen++;
263 			if (seen == occurrence) {
264 				matcher.appendReplacement(result, repl);
265 				break;
266 			}
267 		}
268 		matcher.appendTail(result);
269 		return result.toString();
270 	}
271 
272 	/**
273 	 * Returns the current time in seconds since the epoch.
274 	 *
275 	 * @return seconds since 1970-01-01 00:00:00 UTC
276 	 */
277 	@JawkFunction("systime")
278 	public Long systime() {
279 		return Long.valueOf(System.currentTimeMillis() / 1000L);
280 	}
281 
282 	/**
283 	 * Converts a gawk {@code "YYYY MM DD HH MM SS [DST]"} date specification
284 	 * into seconds since the epoch, normalizing out-of-range values.
285 	 * <p>
286 	 * The conversion follows Java's calendar rules: an ambiguous wall time
287 	 * during a DST fall-back resolves to its standard-time occurrence, and a
288 	 * positive DST hint applies the zone's current savings (zones without DST
289 	 * ignore the hint). See the documented differences with gawk, whose
290 	 * behavior in these edge cases follows the C library.
291 	 *
292 	 * @param datespec date specification with six or seven numeric fields
293 	 * @param utcFlag when truthy, interpret the specification as UTC
294 	 * @return seconds since the epoch, or -1 when the specification is invalid
295 	 */
296 	@JawkFunction("mktime")
297 	public Long mktime(Object datespec, @JawkOptional Object utcFlag) {
298 		String[] fields = toAwkString(datespec).trim().split("\\s+");
299 		if (fields.length < 6 || fields.length > 7) {
300 			return Long.valueOf(-1L);
301 		}
302 		int[] values = new int[fields.length];
303 		for (int i = 0; i < fields.length; i++) {
304 			try {
305 				values[i] = Integer.parseInt(fields[i]);
306 			} catch (NumberFormatException e) {
307 				return Long.valueOf(-1L);
308 			}
309 		}
310 		boolean utc = utcFlag != null && getJrt().toBoolean(utcFlag);
311 		TimeZone timeZone = utc ? TimeZone.getTimeZone("UTC") : localTimeZone();
312 		GregorianCalendar calendar = new GregorianCalendar(timeZone);
313 		// proleptic Gregorian: gawk's civil dates never switch to Julian
314 		calendar.setGregorianChange(new Date(Long.MIN_VALUE));
315 		calendar.setLenient(true);
316 		calendar.clear();
317 		calendar.set(values[0], values[1] - 1, values[2], values[3], values[4], values[5]);
318 		if (fields.length == 7 && !utc && values[6] >= 0) {
319 			// like C's tm_isdst: a non-negative hint forces the DST offset, a
320 			// negative one lets the zone's rules decide
321 			calendar.set(Calendar.DST_OFFSET, values[6] > 0 ? timeZone.getDSTSavings() : 0);
322 		}
323 		return Long.valueOf(Math.floorDiv(calendar.getTimeInMillis(), 1000L));
324 	}
325 
326 	/**
327 	 * Formats a timestamp with C {@code strftime(3)} conversion specifiers.
328 	 *
329 	 * @param format format string; defaults to {@code PROCINFO["strftime"]} or
330 	 *        gawk's {@code "%a %b %e %H:%M:%S %Z %Y"}
331 	 * @param timestamp seconds since the epoch; defaults to the current time
332 	 * @param utcFlag when truthy, format in UTC instead of the local time zone
333 	 * @return formatted timestamp
334 	 */
335 	@JawkFunction("strftime")
336 	public String strftime(
337 			@JawkOptional Object format,
338 			@JawkOptional Object timestamp,
339 			@JawkOptional Object utcFlag) {
340 		String formatString = format == null ? defaultStrftimeFormat() : toAwkString(format);
341 		long seconds = timestamp == null ?
342 				System.currentTimeMillis() / 1000L : (long) JRT.toDouble(timestamp);
343 		boolean utc = utcFlag != null && getJrt().toBoolean(utcFlag);
344 		TimeZone timeZone = utc ? TimeZone.getTimeZone("UTC") : localTimeZone();
345 		return Strftime.format(formatString, seconds, timeZone);
346 	}
347 
348 	/**
349 	 * Returns the local time zone for {@code mktime()} and {@code strftime()},
350 	 * honoring {@code ENVIRON["TZ"]}: gawk supports changing the time zone
351 	 * from within the script through the AWK environment. The value is
352 	 * resolved with Java's time zone semantics: any zone ID that
353 	 * {@link TimeZone#getTimeZone(String)} understands (Olson names such as
354 	 * {@code America/New_York}, custom IDs such as {@code GMT+3} with Java's
355 	 * sign convention); POSIX TZ rule specifications are not parsed, and
356 	 * unknown zones fall back to GMT.
357 	 */
358 	private TimeZone localTimeZone() {
359 		Object environ = getVm().getVariable("ENVIRON");
360 		if (environ instanceof Map) {
361 			@SuppressWarnings("unchecked")
362 			Map<Object, Object> environMap = (Map<Object, Object>) environ;
363 			String tz = getJrt().getAwkStringEntry(environMap, "TZ");
364 			if (tz != null) {
365 				if (tz.startsWith(":")) {
366 					// POSIX: a leading colon introduces an implementation-defined
367 					// (here: Olson) time zone name
368 					tz = tz.substring(1);
369 				}
370 				// POSIX: an explicitly empty TZ means UTC
371 				return TimeZone.getTimeZone(tz.isEmpty() ? "UTC" : tz);
372 			}
373 		}
374 		return TimeZone.getDefault();
375 	}
376 
377 	/** Returns {@code PROCINFO["strftime"]} when set, gawk's default format otherwise. */
378 	private String defaultStrftimeFormat() {
379 		Object procinfo = getVm().getVariable("PROCINFO");
380 		if (procinfo instanceof Map) {
381 			@SuppressWarnings("unchecked")
382 			Map<Object, Object> procinfoMap = (Map<Object, Object>) procinfo;
383 			String format = getJrt().getAwkStringEntry(procinfoMap, "strftime");
384 			if (format != null) {
385 				return format;
386 			}
387 		}
388 		return DEFAULT_STRFTIME_FORMAT;
389 	}
390 
391 	/**
392 	 * Converts a string to a number, recognizing gawk's non-decimal notation:
393 	 * a {@code 0x} prefix selects hexadecimal and a leading {@code 0} over
394 	 * octal digits selects octal.
395 	 *
396 	 * @param value value to convert
397 	 * @return numeric value
398 	 */
399 	@JawkFunction("strtonum")
400 	public Number strtonum(@JawkRawValue Object value) {
401 		if (value instanceof Number) {
402 			return (Number) value;
403 		}
404 		if (value instanceof StrNum && ((StrNum) value).isNumber()) {
405 			// gawk resolves numeric-looking input fields to plain numbers
406 			// before looking at the base, so "011" from input is decimal 11
407 			return Double.valueOf(((StrNum) value).doubleValue());
408 		}
409 		String text = toAwkString(value);
410 		switch (numberBase(text)) {
411 		case 16:
412 			return parseNonDecimal(text, 2, 16);
413 		case 8:
414 			return parseNonDecimal(text, 1, 8);
415 		default:
416 			return Double.valueOf(JRT.toDouble(text));
417 		}
418 	}
419 
420 	/**
421 	 * Determines the numeric base of a string constant, as gawk does: a
422 	 * {@code 0x}/{@code 0X} prefix means hexadecimal, and a leading zero means
423 	 * octal unless the token is really a decimal number. Scanning the
424 	 * contiguous digit prefix, a digit above 7 or an adjacent decimal point or
425 	 * exponent makes the constant decimal (so {@code 019} is 19 and
426 	 * {@code 011e2} is 1100), while any other character merely terminates the
427 	 * numeric token (so {@code 011x} is 9 and {@code 077foo.5} is 63).
428 	 * <p>
429 	 * This deliberately lives here and not in {@link JRT}'s input conversion:
430 	 * POSIX numeric strings are strictly decimal, so input fields never get
431 	 * hexadecimal or octal interpretation (gawk applies it only under
432 	 * {@code --non-decimal-data}, which Jawk does not implement);
433 	 * {@code strtonum()} is the explicit gateway to non-decimal notation.
434 	 */
435 	private static int numberBase(String text) {
436 		if (text.length() < 2 || text.charAt(0) != '0') {
437 			return 10;
438 		}
439 		char second = text.charAt(1);
440 		if (second == 'x' || second == 'X') {
441 			return 16;
442 		}
443 		for (int i = 1; i < text.length(); i++) {
444 			char c = text.charAt(i);
445 			if (c == '.' || c == 'e' || c == 'E') {
446 				return 10;
447 			}
448 			if (c < '0' || c > '9') {
449 				break;
450 			}
451 			if (c > '7') {
452 				return 10;
453 			}
454 		}
455 		return 8;
456 	}
457 
458 	/**
459 	 * Parses the digits of the given base, stopping at the first invalid
460 	 * character, as gawk's non-decimal scanner does ({@code "0x"} is 0,
461 	 * {@code "011x"} is 9). Values beyond the long range degrade to the
462 	 * nearest double, as in gawk.
463 	 */
464 	private static Number parseNonDecimal(String text, int offset, int base) {
465 		int end = offset;
466 		while (end < text.length() && Character.digit(text.charAt(end), base) >= 0) {
467 			end++;
468 		}
469 		if (end == offset) {
470 			return Long.valueOf(0L);
471 		}
472 		String digits = text.substring(offset, end);
473 		try {
474 			return Long.valueOf(Long.parseLong(digits, base));
475 		} catch (NumberFormatException overflow) {
476 			return Double.valueOf(new BigInteger(digits, base).doubleValue());
477 		}
478 	}
479 
480 	/**
481 	 * Splits a string by content: pieces matching {@code fieldpat} become
482 	 * fields, the text between them becomes separators. This is gawk's
483 	 * {@code patsplit()}, the function form of {@code FPAT} field splitting.
484 	 *
485 	 * @param source text to split
486 	 * @param array destination array for the fields
487 	 * @param fieldpat field pattern, or {@code null} to use the {@code FPAT}
488 	 *        global variable (default {@code "[^[:space:]]+"})
489 	 * @param seps optional destination array for the separators; entry 0 holds
490 	 *        the text before the first field
491 	 * @return number of fields
492 	 */
493 	@JawkFunction("patsplit")
494 	public Long patsplit(
495 			Object source,
496 			@JawkAssocArray Map<Object, Object> array,
497 			@JawkOptional @JawkRegexp Object fieldpat,
498 			@JawkOptional @JawkAssocArray Map<Object, Object> seps) {
499 		if (array == seps) {
500 			throw new IllegalAwkArgumentException("patsplit: cannot use the same array for second and fourth args");
501 		}
502 		String str = toAwkString(source);
503 		Pattern pattern = fieldPattern(fieldpat);
504 		array.clear();
505 		if (seps != null) {
506 			seps.clear();
507 		}
508 		if (str.isEmpty()) {
509 			return Long.valueOf(0L);
510 		}
511 		/*
512 		 * gawk's FPAT splitting rules: every accepted match becomes a field,
513 		 * except that a zero-length match immediately following a non-empty
514 		 * field is skipped, retrying one character (code point) further. The separators are
515 		 * the gaps around the accepted fields: seps[i] is the text between
516 		 * fields i and i+1, seps[0] the text before the first field, seps[n]
517 		 * the text after the last one. The matcher region makes anchors behave
518 		 * as if the already-consumed prefix were gone, as in gawk.
519 		 */
520 		Matcher matcher = pattern.matcher(str);
521 		int length = str.length();
522 		int pos = 0;
523 		int previousEnd = 0;
524 		long fieldCount = 0L;
525 		boolean lastMatchNonEmpty = false;
526 		while (pos <= length) {
527 			matcher.region(pos, length);
528 			if (!matcher.find()) {
529 				break;
530 			}
531 			int start = matcher.start();
532 			int end = matcher.end();
533 			if (end > start) {
534 				lastMatchNonEmpty = true;
535 				putSeparator(seps, fieldCount, str.substring(previousEnd, start));
536 				array.put(Long.valueOf(++fieldCount), getJrt().toInputScalar(str.substring(start, end)));
537 				previousEnd = end;
538 				pos = end;
539 				if (pos >= length) {
540 					break;
541 				}
542 			} else if (lastMatchNonEmpty) {
543 				lastMatchNonEmpty = false;
544 				pos = str.offsetByCodePoints(pos, 1);
545 			} else {
546 				putSeparator(seps, fieldCount, str.substring(previousEnd, start));
547 				array.put(Long.valueOf(++fieldCount), getJrt().toInputScalar(""));
548 				previousEnd = start;
549 				if (start >= length) {
550 					// trailing empty field at end of input: done
551 					break;
552 				}
553 				pos = str.offsetByCodePoints(start, 1);
554 			}
555 		}
556 		// seps[n] holds the text after the last field: the rest of the input,
557 		// the empty string when the input ends at a field boundary
558 		putSeparator(seps, fieldCount, str.substring(previousEnd));
559 		return Long.valueOf(fieldCount);
560 	}
561 
562 	/** Stores a separator, unless the caller omitted the separator array. */
563 	private void putSeparator(Map<Object, Object> separators, long index, String value) {
564 		if (separators != null) {
565 			separators.put(Long.valueOf(index), getJrt().toInputScalar(value));
566 		}
567 	}
568 
569 	/**
570 	 * Resolves the {@code patsplit()} field pattern: argument, FPAT, or gawk's
571 	 * default. Jawk has no FPAT special variable, so an unset FPAT stands in
572 	 * for gawk's built-in default; an explicitly empty pattern is fatal, as in
573 	 * gawk.
574 	 */
575 	private Pattern fieldPattern(Object fieldpat) {
576 		if (fieldpat instanceof Pattern) {
577 			Pattern pattern = (Pattern) fieldpat;
578 			requireNonEmptyFieldPattern(pattern.pattern());
579 			return getJrt().caseAwarePattern(pattern);
580 		}
581 		String expression;
582 		if (fieldpat != null) {
583 			expression = toAwkString(fieldpat);
584 		} else {
585 			Object fpat = getVm().getVariable("FPAT");
586 			if (fpat == null || fpat instanceof UninitializedObject) {
587 				return getJrt().dynamicPattern(DEFAULT_FPAT);
588 			}
589 			expression = toAwkString(fpat);
590 		}
591 		requireNonEmptyFieldPattern(expression);
592 		return getJrt().dynamicPattern(expression);
593 	}
594 
595 	/** Rejects an empty field pattern with gawk's fatal diagnostic. */
596 	private static void requireNonEmptyFieldPattern(String expression) {
597 		if (expression.isEmpty()) {
598 			throw new IllegalAwkArgumentException("patsplit: field pattern must be non-null");
599 		}
600 	}
601 
602 	/**
603 	 * Returns the translation of a string in the given text domain and locale
604 	 * category. Jawk ships no message catalogs, so the text is returned
605 	 * untranslated, exactly like gawk without a matching {@code .mo} file.
606 	 *
607 	 * @param string text to translate
608 	 * @param domain text domain; defaults to {@code TEXTDOMAIN}
609 	 * @param category locale category; validated, then ignored (no catalogs)
610 	 * @return the untranslated text
611 	 */
612 	@JawkFunction("dcgettext")
613 	public String dcgettext(Object string, @JawkOptional Object domain, @JawkOptional Object category) {
614 		checkLocaleCategory(category);
615 		// no .mo catalog support yet (issue #530): behave like gawk built
616 		// without gettext, whose own test suite accepts this as passing
617 		return toAwkString(string);
618 	}
619 
620 	/**
621 	 * Returns the singular or plural form of a message according to a number.
622 	 * Without message catalogs this applies the English plural rule, exactly
623 	 * like gawk without a matching {@code .mo} file.
624 	 *
625 	 * @param singular singular form
626 	 * @param plural plural form
627 	 * @param number quantity deciding the form
628 	 * @param domain text domain; defaults to {@code TEXTDOMAIN}
629 	 * @param category locale category; validated, then ignored (no catalogs)
630 	 * @return {@code singular} when the number is 1, {@code plural} otherwise
631 	 */
632 	@JawkFunction("dcngettext")
633 	public String dcngettext(
634 			Object singular,
635 			Object plural,
636 			Object number,
637 			@JawkOptional Object domain,
638 			@JawkOptional Object category) {
639 		checkLocaleCategory(category);
640 		return (long) JRT.toDouble(number) == 1L ? toAwkString(singular) : toAwkString(plural);
641 	}
642 
643 	/**
644 	 * Rejects invalid locale category arguments with gawk's fatal diagnostic,
645 	 * which names dcgettext even for {@code dcngettext()}.
646 	 */
647 	private void checkLocaleCategory(Object category) {
648 		if (category == null) {
649 			return;
650 		}
651 		String name = toAwkString(category);
652 		if (!LOCALE_CATEGORIES.contains(name)) {
653 			throw new IllegalAwkArgumentException("dcgettext: `" + name + "' is not a valid locale category");
654 		}
655 	}
656 
657 	/**
658 	 * Binds a text domain to a message catalog directory and returns the
659 	 * binding, mirroring gawk's {@code bindtextdomain()}.
660 	 *
661 	 * @param directory directory to bind; the AWK empty string queries the
662 	 *        current binding without changing it
663 	 * @param domain text domain; defaults to {@code TEXTDOMAIN}
664 	 * @return the directory now bound to the domain
665 	 */
666 	@JawkFunction("bindtextdomain")
667 	public String bindtextdomain(Object directory, @JawkOptional Object domain) {
668 		String domainName = domain == null ? currentTextdomain() : toAwkString(domain);
669 		if (domainName.isEmpty()) {
670 			// C's bindtextdomain() rejects an explicitly empty domain: gawk
671 			// returns the empty string and no binding changes
672 			return "";
673 		}
674 		String directoryName = toAwkString(directory);
675 		if (textdomainBindings == null) {
676 			textdomainBindings = new HashMap<String, String>();
677 		}
678 		if (!directoryName.isEmpty()) {
679 			textdomainBindings.put(domainName, directoryName);
680 		}
681 		String bound = textdomainBindings.get(domainName);
682 		return bound == null ? DEFAULT_LOCALE_DIRECTORY : bound;
683 	}
684 
685 	/** Returns the {@code TEXTDOMAIN} variable, or gawk's default domain when unset. */
686 	private String currentTextdomain() {
687 		Object textdomain = getVm().getVariable("TEXTDOMAIN");
688 		String name = textdomain == null ? "" : toAwkString(textdomain);
689 		return name.isEmpty() ? DEFAULT_TEXTDOMAIN : name;
690 	}
691 
692 	/**
693 	 * Prints a gawk-style diagnostic located at the extension call currently
694 	 * being dispatched, e.g. {@code gawk: script.awk:4: warning: ...}.
695 	 */
696 	private void warnAtCurrentLine(String format, Object... args) {
697 		String source = avm == null ? null : avm.getSourceDescription();
698 		String basename = source == null ? "" : new File(source).getName();
699 		getJrt()
700 				.printWarning(
701 						String
702 								.format(
703 										"gawk: %s:%d: warning: %s",
704 										basename,
705 										avm == null ? 0 : avm.getCurrentLineNumber(),
706 										String.format(format, args)));
707 	}
708 
709 	/**
710 	 * Returns the {@code for (index in array)} traversal order mandated by
711 	 * {@code PROCINFO["sorted_in"]}, or the array's natural key order when no
712 	 * sort mode is in effect.
713 	 */
714 	private Collection<Object> orderForInKeys(Map<Object, Object> map) {
715 		String mode = currentSortedIn();
716 		if (mode == null || mode.isEmpty() || "@unsorted".equals(mode)) {
717 			return map.keySet();
718 		}
719 		return sortedKeys(map, effectiveSortMode(mode, VAL_TYPE_ASC), getJrt(), currentIgnoreCase());
720 	}
721 
722 	private String currentSortedIn() {
723 		Object procinfo = getVm().getVariable("PROCINFO");
724 		if (!(procinfo instanceof Map)) {
725 			return null;
726 		}
727 		@SuppressWarnings("unchecked")
728 		Map<Object, Object> procinfoMap = (Map<Object, Object>) procinfo;
729 		return getJrt().getAwkStringEntry(procinfoMap, "sorted_in");
730 	}
731 
732 	/*
733 	 * Gawk also accepts the name of a user-defined comparison function, which
734 	 * Jawk does not support: those fall back to the default ordering with a
735 	 * one-time warning. Unknown @-modes are typos and stay fatal, as in gawk.
736 	 */
737 	private String effectiveSortMode(String mode, String defaultMode) {
738 		if (mode.isEmpty()) {
739 			// gawk treats an empty mode like an omitted one
740 			return defaultMode;
741 		}
742 		if (mode.charAt(0) != '@') {
743 			warnUnsupportedComparator(mode);
744 			return defaultMode;
745 		}
746 		return mode;
747 	}
748 
749 	private void warnUnsupportedComparator(String name) {
750 		if (warnedComparators == null) {
751 			warnedComparators = new HashSet<String>();
752 		}
753 		if (warnedComparators.add(name)) {
754 			warnAtCurrentLine("sort comparison function `%s' is not supported; using default ordering", name);
755 		}
756 	}
757 
758 	private Long sort(Map<Object, Object> source, Map<Object, Object> dest, Object how, boolean indicesAsValues) {
759 		Map<Object, Object> destination = dest == null ? source : dest;
760 		// gawk's defaults: value-type order for asort(), string index order
761 		// for asorti() (indexes are strings, so no type ranking applies)
762 		String defaultMode = indicesAsValues ? "@ind_str_asc" : VAL_TYPE_ASC;
763 		String mode = how == null ? defaultMode : effectiveSortMode(toAwkString(how), defaultMode);
764 		List<SortEntry> entries = entries(source);
765 		// @unsorted keeps the natural traversal order: no sorting at all
766 		if (!"@unsorted".equals(mode)) {
767 			Collections.sort(entries, comparator(mode, getJrt(), currentIgnoreCase()));
768 		}
769 		destination.clear();
770 		long idx = 1L;
771 		for (SortEntry entry : entries) {
772 			// asorti() writes indices as string values, as in gawk: the
773 			// internal key object may be a Long for numeric-looking indexes
774 			Object value = indicesAsValues ? getJrt().toAwkString(entry.index) : entry.value;
775 			destination.put(Long.valueOf(idx++), value);
776 		}
777 		return Long.valueOf(entries.size());
778 	}
779 
780 	/**
781 	 * Sorts and returns map keys according to a gawk predefined sort mode.
782 	 *
783 	 * @param map map whose keys should be sorted
784 	 * @param mode predefined sort mode
785 	 * @param jrt runtime used for AWK string conversion
786 	 * @param ignoreCase whether string comparisons ignore case
787 	 * @return sorted keys
788 	 */
789 	private static List<Object> sortedKeys(Map<Object, Object> map, String mode, JRT jrt, boolean ignoreCase) {
790 		List<SortEntry> entries = entries(map);
791 		Collections.sort(entries, comparator(mode, jrt, ignoreCase));
792 		List<Object> keys = new ArrayList<Object>(entries.size());
793 		for (SortEntry entry : entries) {
794 			keys.add(entry.index);
795 		}
796 		return keys;
797 	}
798 
799 	private static List<SortEntry> entries(Map<Object, Object> map) {
800 		List<SortEntry> entries = new ArrayList<SortEntry>(map.size());
801 		for (Map.Entry<Object, Object> entry : map.entrySet()) {
802 			entries.add(new SortEntry(entry.getKey(), entry.getValue()));
803 		}
804 		return entries;
805 	}
806 
807 	private boolean currentIgnoreCase() {
808 		return getJrt().isIgnoreCase();
809 	}
810 
811 	/*
812 	 * Callers handle @unsorted before reaching this point (no sort at all),
813 	 * both for asort()/asorti() and for the for-in traversal hook.
814 	 */
815 	private static Comparator<SortEntry> comparator(String effectiveMode, JRT jrt, boolean ignoreCase) {
816 		boolean desc = effectiveMode.endsWith("_desc");
817 		Comparator<SortEntry> comparator;
818 		/*
819 		 * Gawk's predefined orderings first choose whether indexes or values are
820 		 * compared, then choose numeric, string, or type-aware comparison. Arrays
821 		 * are kept in a separate group because gawk does not stringify subarrays
822 		 * during type-aware sorting. Unknown modes are fatal, as in gawk;
823 		 * function-name comparators are not supported.
824 		 */
825 		switch (effectiveMode) {
826 		case "@ind_num_asc":
827 		case "@ind_num_desc":
828 			comparator = (left, right) -> compareNumericThenText(left.index, right.index, jrt, ignoreCase);
829 			break;
830 		case "@ind_str_asc":
831 		case "@ind_str_desc":
832 			comparator = (left, right) -> compareStrings(left.index, right.index, jrt, ignoreCase);
833 			break;
834 		case "@ind_type_asc":
835 		case "@ind_type_desc":
836 			comparator = (left, right) -> compareByTypeThenValue(left.index, right.index, jrt, ignoreCase);
837 			break;
838 		case "@val_num_asc":
839 		case "@val_num_desc":
840 			comparator = (left, right) -> compareNumericThenText(left.value, right.value, jrt, ignoreCase);
841 			break;
842 		case "@val_str_asc":
843 		case "@val_str_desc":
844 			comparator = (left, right) -> compareStrings(left.value, right.value, jrt, ignoreCase);
845 			break;
846 		case "@val_type_asc":
847 		case "@val_type_desc":
848 			comparator = (left, right) -> compareByTypeThenValue(left.value, right.value, jrt, ignoreCase);
849 			break;
850 		default:
851 			throw new IllegalAwkArgumentException("Invalid sort comparison mode '" + effectiveMode + "'");
852 		}
853 		return desc ? comparator.reversed() : comparator;
854 	}
855 
856 	/*
857 	 * The comparators below implement gawk's predefined sort orderings
858 	 * (@ind_num_*, @val_str_*, @val_type_*, ...). They cannot reuse
859 	 * JRT.compare2(): that method implements AWK's relational operators
860 	 * (boolean outcome, strnum coercion rules), while these need a three-way
861 	 * ordering that first RANKS values by gawk type (numbers < strings <
862 	 * subarrays, in mode-specific order) and then compares within the rank
863 	 * using a comparison FORCED by the mode (numeric or string), honoring
864 	 * IGNORECASE for strings.
865 	 */
866 
867 	/**
868 	 * Type-aware ordering used by the {@code @..._type_...} modes and as gawk's
869 	 * default: numbers and strnums first (compared numerically), then strings
870 	 * (compared as text), then subarrays (mutually unordered).
871 	 */
872 	private static int compareByTypeThenValue(Object left, Object right, JRT jrt, boolean ignoreCase) {
873 		int leftRank = typeRank(left);
874 		int rightRank = typeRank(right);
875 		if (leftRank != rightRank) {
876 			return Integer.compare(leftRank, rightRank);
877 		}
878 		if (left instanceof Map && right instanceof Map) {
879 			return 0;
880 		}
881 		if (leftRank == 0) {
882 			return compareNumbers(left, right);
883 		}
884 		return compareStrings(left, right, jrt, ignoreCase);
885 	}
886 
887 	/** Rank for type-aware ordering: 0 = number/strnum, 1 = string, 2 = subarray. */
888 	private static int typeRank(Object value) {
889 		if (value instanceof Number || isStrnum(value)) {
890 			return 0;
891 		}
892 		if (value instanceof Map) {
893 			return 2;
894 		}
895 		return 1;
896 	}
897 
898 	/** Numeric three-way comparison; subarrays sort as 0 so ranking decides first. */
899 	private static int compareNumbers(Object left, Object right) {
900 		return Double.compare(numericSortValue(left), numericSortValue(right));
901 	}
902 
903 	private static double numericSortValue(Object value) {
904 		return value instanceof Map ? 0.0D : JRT.toDouble(value);
905 	}
906 
907 	/**
908 	 * Ordering for the {@code @..._num_...} modes: gawk coerces every scalar to
909 	 * a number (non-numeric strings count as 0), breaks numeric ties with a
910 	 * string comparison, and sorts subarrays last.
911 	 */
912 	private static int compareNumericThenText(Object left, Object right, JRT jrt, boolean ignoreCase) {
913 		if (left instanceof Map || right instanceof Map) {
914 			if (left instanceof Map && right instanceof Map) {
915 				return 0;
916 			}
917 			return left instanceof Map ? 1 : -1;
918 		}
919 		int numeric = compareNumbers(left, right);
920 		if (numeric != 0) {
921 			return numeric;
922 		}
923 		return compareStrings(left, right, jrt, ignoreCase);
924 	}
925 
926 	/**
927 	 * Text three-way comparison through {@code toAwkString} (CONVFMT/locale),
928 	 * folding case when {@code IGNORECASE} is set; subarrays sort last.
929 	 */
930 	private static int compareStrings(Object left, Object right, JRT jrt, boolean ignoreCase) {
931 		if (left instanceof Map || right instanceof Map) {
932 			if (left instanceof Map && right instanceof Map) {
933 				return 0;
934 			}
935 			return left instanceof Map ? 1 : -1;
936 		}
937 		String leftString = jrt.toAwkString(left);
938 		String rightString = jrt.toAwkString(right);
939 		// compareToIgnoreCase folds per character without allocating, applying
940 		// the same rule as JRT.compare2 on string relational operators
941 		return ignoreCase ? leftString.compareToIgnoreCase(rightString) : leftString.compareTo(rightString);
942 	}
943 
944 	private static String typeOf(Object value) {
945 		if (value == null || value instanceof UntypedObject) {
946 			return "untyped";
947 		}
948 		if (value instanceof Map) {
949 			return "array";
950 		}
951 		if (value instanceof GawkBool) {
952 			return "number|bool";
953 		}
954 		if (value instanceof Number) {
955 			return "number";
956 		}
957 		if (value instanceof Pattern) {
958 			return "regexp";
959 		}
960 		if (value instanceof UninitializedObject) {
961 			return "unassigned";
962 		}
963 		return isStrnum(value) ? "strnum" : "string";
964 	}
965 
966 	private static boolean isStrnum(Object value) {
967 		return value instanceof StrNum && ((StrNum) value).isNumber();
968 	}
969 
970 	private static String arrayType(Map<?, ?> map) {
971 		if (map.isEmpty()) {
972 			return "null";
973 		}
974 		boolean allNonNegativeIntegral = true;
975 		for (Object key : map.keySet()) {
976 			if (!(key instanceof Number)) {
977 				return "str";
978 			}
979 			long longValue = ((Number) key).longValue();
980 			double doubleValue = ((Number) key).doubleValue();
981 			if (Double.compare(doubleValue, (double) longValue) != 0) {
982 				return "str";
983 			}
984 			if (longValue < 0) {
985 				allNonNegativeIntegral = false;
986 			}
987 		}
988 		return allNonNegativeIntegral ? "cint" : "int";
989 	}
990 }