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