diff --git a/pom.xml b/pom.xml index 3112bb0d..6d63fab6 100644 --- a/pom.xml +++ b/pom.xml @@ -96,12 +96,6 @@ - - org.metricshub - printf4j - 0.9.08 - - com.github.stefanbirkner system-rules diff --git a/src/main/java/io/jawk/backend/AVM.java b/src/main/java/io/jawk/backend/AVM.java index 04665f9a..7bf3f06a 100644 --- a/src/main/java/io/jawk/backend/AVM.java +++ b/src/main/java/io/jawk/backend/AVM.java @@ -80,6 +80,7 @@ import io.jawk.intermediate.UninitializedObject; import io.jawk.intermediate.UntypedObject; import io.jawk.jrt.AssocArray; +import io.jawk.jrt.AwkPrintf; import io.jawk.jrt.AwkRuntimeException; import io.jawk.jrt.AwkSink; import io.jawk.jrt.BlockManager; @@ -1785,7 +1786,7 @@ private void executeTuples(PositionTracker position) } case INTFUNC: { // stack[0] = arg to int() function - push((long) JRT.toDouble(pop())); + push(JRT.truncateToScalar(JRT.toDouble(pop()))); position.next(); break; } @@ -2830,7 +2831,7 @@ private void execPrintToPipe(CountTuple tuple) throws IOException { private void execPrintf(CountTuple tuple) throws IOException { long numArgs = tuple.getCount(); Object[] values = popArguments(numArgs - 1); - String format = jrt.toAwkString(pop()); + String format = checkPosixFormat(jrt.toAwkString(pop())); jrt.printfDefault(format, values); } @@ -2838,7 +2839,7 @@ private void execPrintfToFile(CountAndAppendTuple tuple) throws IOException { String key = jrt.toAwkString(pop()); long numArgs = tuple.getCount(); Object[] values = popArguments(numArgs - 1); - String format = jrt.toAwkString(pop()); + String format = checkPosixFormat(jrt.toAwkString(pop())); jrt.printfToFile(key, tuple.isAppend(), format, values); } @@ -2846,7 +2847,7 @@ private void execPrintfToPipe(CountTuple tuple) throws IOException { String cmd = jrt.toAwkString(pop()); long numArgs = tuple.getCount(); Object[] values = popArguments(numArgs - 1); - String format = jrt.toAwkString(pop()); + String format = checkPosixFormat(jrt.toAwkString(pop())); jrt.printfToProcess(cmd, format, values); } @@ -3003,7 +3004,7 @@ private Object invokeIndirectBuiltin( return jrt.index(jrt.toAwkString(args[0]), jrt.toAwkString(args[1])); case INT: requireIndirectArgumentCount(builtin, args, 1, 1, lineNumber); - return Long.valueOf((long) JRT.toDouble(args[0])); + return JRT.truncateToScalar(JRT.toDouble(args[0])); case LENGTH: requireIndirectArgumentCount(builtin, args, 0, 1, lineNumber); return args.length == 0 ? Integer.valueOf(jrt.jrtGetInputField(0).toString().length()) : lengthOf(args[0]); @@ -3029,9 +3030,8 @@ private Object invokeIndirectBuiltin( case SPRINTF: requireIndirectArgumentCount(builtin, args, 1, Integer.MAX_VALUE, lineNumber); return jrt - .getAwkSink() .sprintf( - jrt.toAwkString(args[0]), + checkPosixFormat(jrt.toAwkString(args[0])), Arrays.copyOfRange(args, 1, args.length)); case SQRT: requireIndirectArgumentCount(builtin, args, 1, 1, lineNumber); @@ -3783,8 +3783,19 @@ private Object[] popArguments(long numArgs) { */ private String sprintfFunction(long numArgs) { Object[] argArray = popArguments(numArgs - 1); - String fmt = jrt.toAwkString(pop()); - return jrt.getAwkSink().sprintf(fmt, argArray); + String fmt = checkPosixFormat(jrt.toAwkString(pop())); + return jrt.sprintf(fmt, argArray); + } + + /** + * Rejects gawk positional argument references in strict POSIX mode, like + * {@code gawk --posix}. + */ + private String checkPosixFormat(String format) { + if (settings.isPosix() && AwkPrintf.usesPositionalArguments(format)) { + throw new AwkRuntimeException("`$' is not permitted in awk formats"); + } + return format; } private void setNumOnJRT(long fieldNum, double num) { diff --git a/src/main/java/io/jawk/intermediate/AwkTuples.java b/src/main/java/io/jawk/intermediate/AwkTuples.java index 31090ab2..b98e3c38 100644 --- a/src/main/java/io/jawk/intermediate/AwkTuples.java +++ b/src/main/java/io/jawk/intermediate/AwkTuples.java @@ -2383,55 +2383,37 @@ private Object foldBinary(Object left, Object right, Tuple operation) { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = d1 + d2; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case SUBTRACT: { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = d1 - d2; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case MULTIPLY: { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = d1 * d2; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case DIVIDE: { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = d1 / d2; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case MOD: { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = d1 % d2; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case POW: { double d1 = JRT.toDouble(left); double d2 = JRT.toDouble(right); double ans = Math.pow(d1, d2); - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case CMP_EQ: case CMP_LT: @@ -2462,17 +2444,11 @@ private Object foldUnary(Object literal, Tuple operation) { case NEGATE: { double value = JRT.toDouble(literal); double ans = -value; - if (JRT.isActuallyLong(ans)) { - return Long.valueOf((long) Math.rint(ans)); - } - return Double.valueOf(ans); + return JRT.toScalarNumber(ans); } case UNARY_PLUS: { double value = JRT.toDouble(literal); - if (JRT.isActuallyLong(value)) { - return Long.valueOf((long) Math.rint(value)); - } - return Double.valueOf(value); + return JRT.toScalarNumber(value); } default: return null; @@ -2488,11 +2464,11 @@ private Tuple createLiteralPush(Object value, int lineNumber) { } else if (value instanceof Double) { tuple = new Tuple.PushDoubleTuple(((Double) value).doubleValue()); } else if (value instanceof Number) { - double d = ((Number) value).doubleValue(); - if (JRT.isActuallyLong(d)) { - tuple = new Tuple.PushLongTuple((long) Math.rint(d)); + Object scalar = JRT.toScalarNumber(((Number) value).doubleValue()); + if (scalar instanceof Long) { + tuple = new Tuple.PushLongTuple(((Long) scalar).longValue()); } else { - tuple = new Tuple.PushDoubleTuple(d); + tuple = new Tuple.PushDoubleTuple(((Double) scalar).doubleValue()); } } else if (value instanceof String) { tuple = new Tuple.PushStringTuple((String) value); diff --git a/src/main/java/io/jawk/jrt/AppendableAwkSink.java b/src/main/java/io/jawk/jrt/AppendableAwkSink.java index 82930bef..a362ab83 100644 --- a/src/main/java/io/jawk/jrt/AppendableAwkSink.java +++ b/src/main/java/io/jawk/jrt/AppendableAwkSink.java @@ -89,10 +89,10 @@ public void print(String ofs, String ors, String ofmt, Object... values) throws } @Override - public void printf(String ofs, String ors, String ofmt, String format, Object... values) + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) throws IOException { synchronized (lock) { - appendable.append(formatPrintfResult(format, values)); + appendable.append(sprintf(convfmt, format, values)); } } diff --git a/src/main/java/io/jawk/jrt/AwkPrintf.java b/src/main/java/io/jawk/jrt/AwkPrintf.java new file mode 100644 index 00000000..afe3fd58 --- /dev/null +++ b/src/main/java/io/jawk/jrt/AwkPrintf.java @@ -0,0 +1,1018 @@ +package io.jawk.jrt; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.MathContext; +import java.math.RoundingMode; +import java.text.DecimalFormatSymbols; +import java.util.IllegalFormatException; +import java.util.Locale; + +/** + * AWK's {@code printf}/{@code sprintf} formatting engine. + *

+ * This class implements the POSIX AWK formatting semantics (as implemented by + * gawk), which differ from both C's {@code printf()} and + * {@link java.lang.String#format(String, Object...)} in several ways: + *

+ *
    + *
  • {@code %s} converts numeric values to strings with AWK's number-to-string + * rules: integral values are printed without a fractional part, and other + * values are formatted with {@code CONVFMT};
  • + *
  • {@code %c} prints the character for a numeric code point, or the first + * character of a string value;
  • + *
  • {@code %i} is an alias for {@code %d}, and {@code %u} prints the value + * as an unsigned 64-bit integer;
  • + *
  • dynamic field width and precision ({@code *}) consume arguments, and + * gawk-style positional specifiers ({@code %n$}) are honored;
  • + *
  • integer conversions of values that exceed the 64-bit range fall back to + * the full decimal expansion ({@code %d}/{@code %i}) or {@code %g} notation + * ({@code %u}/{@code %o}/{@code %x}/{@code %X}), like gawk;
  • + *
  • NaN and infinities print as {@code nan}, {@code inf}, and {@code -inf};
  • + *
  • {@code %e}, {@code %f}, and {@code %g} round halfway cases to even, like + * the C library used by gawk;
  • + *
  • unknown conversion specifiers are printed verbatim without consuming an + * argument, and a fatal {@link AwkRuntimeException} is raised when there are + * not enough arguments to satisfy the format string.
  • + *
+ *

+ * This formatting logic was originally externalized in the + * Printf4J project, which + * emulated glibc's {@code printf()}. It has been reincorporated into Jawk and + * adapted to AWK's semantics. + *

+ */ +public final class AwkPrintf { + + /** Default AWK number-to-string conversion format ({@code CONVFMT}). */ + public static final String DEFAULT_CONVFMT = "%.6g"; + + /** Conversion characters recognized as AWK format specifiers. */ + private static final String CONVERSION_CHARS = "diouxXeEfFgGaAcs"; + + /** Length modifier characters accepted (and ignored) like gawk. */ + private static final String LENGTH_MODIFIERS = "hjlLtz"; + + /** A one-character string holding the NUL character, printed by {@code %c} for empty values. */ + private static final String NUL_STRING = Character.toString((char) 0); + + /** 2^63 as a double, the first value beyond the signed 64-bit range. */ + private static final double TWO_POW_63 = 9.223372036854775808e18; + + /** 2^64 as a {@link BigInteger}, used for unsigned wrapping checks. */ + private static final BigInteger TWO_POW_64 = BigInteger.ONE.shiftLeft(64); + + private AwkPrintf() { + throw new UnsupportedOperationException(); + } + + /** + * Formats the given arguments with AWK's {@code sprintf()} semantics, using + * {@link Locale#US} and the default {@code CONVFMT} ({@code "%.6g"}). + * + * @param format AWK format string + * @param args arguments supplied after the format string + * @return the formatted text + * @throws AwkRuntimeException when there are not enough arguments to + * satisfy the format string + */ + public static String sprintf(final String format, final Object... args) { + return sprintf(Locale.US, DEFAULT_CONVFMT, format, args); + } + + /** + * Formats the given arguments with AWK's {@code sprintf()} semantics. + * + * @param locale locale used for numeric formatting (decimal separator, + * grouping separator for the {@code '} flag) + * @param convfmt number-to-string conversion format ({@code CONVFMT}) used + * by {@code %s} for non-integral numeric values + * @param format AWK format string + * @param args arguments supplied after the format string + * @return the formatted text + * @throws AwkRuntimeException when there are not enough arguments to + * satisfy the format string + */ + public static String sprintf(final Locale locale, final String convfmt, final String format, final Object... args) { + Locale actualLocale = locale == null ? Locale.US : locale; + // An explicitly empty CONVFMT stays empty, like gawk; only a null + // (absent) format selects the default. + String actualConvfmt = convfmt == null ? DEFAULT_CONVFMT : convfmt; + Object[] actualArgs = args == null ? new Object[0] : args; + return new AwkPrintfFormatter(actualLocale, actualConvfmt, format, actualArgs).format(); + } + + /** + * Returns whether a format string uses gawk positional argument + * references ({@code %n$} or {@code *n$}), which strict POSIX mode must + * reject: {@code gawk --posix} fails with + * `$' is not permitted in awk formats. + * + * @param format AWK format string + * @return {@code true} when the format references arguments by position + */ + public static boolean usesPositionalArguments(final String format) { + int length = format.length(); + int i = 0; + while (i < length) { + if (format.charAt(i) != '%') { + i++; + continue; + } + i++; + if (i < length && format.charAt(i) == '%') { + i++; + continue; + } + // Inside a specifier, a positional reference can appear right + // after '%' or right after '*'. + boolean positionAllowed = true; + while (i < length) { + char c = format.charAt(i); + if (positionAllowed && isAsciiDigit(c)) { + int digitsEnd = i; + while (digitsEnd < length && isAsciiDigit(format.charAt(digitsEnd))) { + digitsEnd++; + } + if (digitsEnd < length && format.charAt(digitsEnd) == '$') { + return true; + } + } + positionAllowed = c == '*'; + if (c == '%' || CONVERSION_CHARS.indexOf(c) >= 0) { + break; + } + i++; + } + } + return false; + } + + /** + * Converts a value to a string using AWK's number-to-string rules. + *

+ * Non-numeric values are converted with {@code toString()}. Numeric values + * holding an integral value are printed without a fractional part (using + * the full decimal expansion when the value exceeds the 64-bit range), NaN + * and infinities print as {@code nan}, {@code inf}, and {@code -inf}, and + * all other numeric values are formatted with the supplied conversion + * format ({@code CONVFMT} or {@code OFMT}). + *

+ * + * @param value value to convert + * @param conversionFormat number-to-string conversion format + * @param locale locale used for numeric formatting + * @return the AWK string value of {@code value} + */ + public static String toAwkString(final Object value, final String conversionFormat, final Locale locale) { + if (value == null) { + return ""; + } + if (value instanceof Long || value instanceof Integer || value instanceof Short || value instanceof Byte) { + // Preserve exact 64-bit values that a double round-trip would corrupt. + return Long.toString(((Number) value).longValue()); + } + if (!(value instanceof Number)) { + return value.toString(); + } + return numberToAwkString(((Number) value).doubleValue(), conversionFormat, locale); + } + + private static String numberToAwkString(final double number, final String conversionFormat, final Locale locale) { + if (Double.isNaN(number)) { + return "nan"; + } + if (Double.isInfinite(number)) { + return number > 0 ? "inf" : "-inf"; + } + if (JRT.isActuallyLong(number)) { + double rounded = Math.rint(number); + if (rounded >= -TWO_POW_63 && rounded < TWO_POW_63) { + return Long.toString((long) rounded); + } + // The exact binary value of the double is intended: it makes rounding match gawk's C library. + return new BigDecimal(rounded).toBigInteger().toString(); // NOPMD + } + // An explicitly empty CONVFMT/OFMT stays empty, like gawk; only a + // null (absent) format selects the default. + String fmt = conversionFormat == null ? DEFAULT_CONVFMT : conversionFormat; + return sprintf(locale, DEFAULT_CONVFMT, fmt, Double.valueOf(number)); + } + + /** + * Immutable set of conversion flags parsed from one format specifier. + */ + private static final class Flags { + + private final boolean leftJustify; + private final boolean plusSign; + private final boolean spaceSign; + private final boolean zeroPad; + private final boolean alternate; + private final boolean grouping; + + Flags(boolean leftJustify, boolean plusSign, boolean spaceSign, boolean zeroPad, boolean alternate, + boolean grouping) { + this.leftJustify = leftJustify; + this.plusSign = plusSign; + this.spaceSign = spaceSign; + this.zeroPad = zeroPad; + this.alternate = alternate; + this.grouping = grouping; + } + + /** + * Returns these flags with the {@code '} grouping flag cleared, for + * conversions that gawk never groups (octal and hexadecimal). + * + * @return an equivalent flag set without grouping + */ + Flags withoutGrouping() { + return grouping ? new Flags(leftJustify, plusSign, spaceSign, zeroPad, alternate, false) : this; + } + } + + /** + * Stateful single-pass formatter for one {@code sprintf()} call. + */ + private static final class AwkPrintfFormatter { + + private final Locale locale; + private final String convfmt; + private final String format; + private final Object[] args; + private final StringBuilder out; + + /** Index of the next sequential argument to consume. */ + private int argIndex; + + /** Whether a positional ({@code n$}) argument reference was seen. */ + private boolean sawPositional; + + /** Whether a sequential argument reference was seen. */ + private boolean sawSequential; + + AwkPrintfFormatter(Locale locale, String convfmt, String format, Object[] args) { + this.locale = locale; + this.convfmt = convfmt; + this.format = format; + this.args = args; + this.out = new StringBuilder(format.length() + 16); + } + + String format() { + int length = format.length(); + int i = 0; + while (i < length) { + char c = format.charAt(i); + if (c != '%') { + out.append(c); + i++; + continue; + } + if (i + 1 >= length) { + // Dangling '%' at the end of the format: print it verbatim. + out.append('%'); + break; + } + if (format.charAt(i + 1) == '%') { + out.append('%'); + i += 2; + continue; + } + i = formatSpecifier(i); + } + return out.toString(); + } + + /** + * Parses and renders one format specifier starting at {@code start} + * (which points at the {@code '%'}), and returns the index of the + * first character after the specifier. + */ + private int formatSpecifier(int start) { + int length = format.length(); + int i = start + 1; + + // gawk-style positional specifier: %n$... + int argPosition = 0; + int digitsEnd = i; + while (digitsEnd < length && isAsciiDigit(format.charAt(digitsEnd))) { + digitsEnd++; + } + if (digitsEnd > i && digitsEnd < length && format.charAt(digitsEnd) == '$') { + argPosition = parseInt(format, i, digitsEnd); + if (argPosition <= 0) { + throw new AwkRuntimeException("argument index with `$' must be > 0 in `" + format + "'"); + } + i = digitsEnd + 1; + } + + // Flags, in any order and possibly repeated. + boolean leftJustify = false; + boolean plusSign = false; + boolean spaceSign = false; + boolean zeroPad = false; + boolean alternate = false; + boolean grouping = false; + flagLoop: while (i < length) { + switch (format.charAt(i)) { + case '-': + leftJustify = true; + break; + case '+': + plusSign = true; + break; + case ' ': + spaceSign = true; + break; + case '0': + zeroPad = true; + break; + case '#': + alternate = true; + break; + case '\'': + grouping = true; + break; + default: + break flagLoop; + } + i++; + } + + // Field width: digits, or '*' (optionally '*n$'). + int width = -1; + if (i < length && format.charAt(i) == '*') { + i++; + int starArgEnd = starPositionEnd(i); + requireStarPositionTerminated(i, starArgEnd); + long dynamicWidth; + if (starArgEnd > i) { + int starPosition = parseInt(format, i, starArgEnd - 1); + // gawk treats a zero-indexed star operand ("%*0$d") as + // the value zero, without consuming an argument. + dynamicWidth = starPosition == 0 ? 0 : (long) JRT.toDouble(argAt(starPosition)); + i = starArgEnd; + } else { + // A sequential star operand pins the format to sequential + // mode; an explicitly positioned one is neutral. + recordArgumentMode(false); + dynamicWidth = (long) JRT.toDouble(nextArg()); + } + if (dynamicWidth < 0) { + leftJustify = true; + dynamicWidth = -dynamicWidth; + } + width = (int) Math.min(dynamicWidth, Integer.MAX_VALUE); + } else { + int widthEnd = i; + while (widthEnd < length && isAsciiDigit(format.charAt(widthEnd))) { + widthEnd++; + } + if (widthEnd > i) { + width = parseInt(format, i, widthEnd); + i = widthEnd; + } + } + + // Precision: '.' followed by digits (empty means 0), or '.*'. + int precision = -1; + if (i < length && format.charAt(i) == '.') { + i++; + if (i < length && format.charAt(i) == '*') { + i++; + int starArgEnd = starPositionEnd(i); + requireStarPositionTerminated(i, starArgEnd); + long dynamicPrecision; + if (starArgEnd > i) { + int starPosition = parseInt(format, i, starArgEnd - 1); + // Same zero-index rule as the width operand. + dynamicPrecision = starPosition == 0 ? 0 : (long) JRT.toDouble(argAt(starPosition)); + i = starArgEnd; + } else { + // Same sequential-mode tracking as the width operand. + recordArgumentMode(false); + dynamicPrecision = (long) JRT.toDouble(nextArg()); + } + // A negative dynamic precision means "no precision" in C. + if (dynamicPrecision >= 0) { + precision = (int) Math.min(dynamicPrecision, Integer.MAX_VALUE); + } + } else { + int precisionEnd = i; + while (precisionEnd < length && isAsciiDigit(format.charAt(precisionEnd))) { + precisionEnd++; + } + precision = precisionEnd == i ? 0 : parseInt(format, i, precisionEnd); + i = precisionEnd; + } + } + + // Length modifiers (h, j, l, L, t, z) are each accepted at most + // once and ignored, like gawk. A repeated modifier such as "ll" + // or "hh" makes the whole specifier invalid, also like gawk. + int modifierMask = 0; + while (i < length) { + int modifierIndex = LENGTH_MODIFIERS.indexOf(format.charAt(i)); + if (modifierIndex < 0 || (modifierMask & 1 << modifierIndex) != 0) { + break; + } + modifierMask |= 1 << modifierIndex; + i++; + } + + if (i < length && format.charAt(i) == '%') { + // A percent conversion reached through flags, width, or + // precision prints a plain '%' and ignores them all, like + // gawk ("%5%" prints "%"). An explicit position still pins + // the format to positional mode, also like gawk. + if (argPosition > 0) { + recordArgumentMode(true); + requireArgumentIndex(argPosition); + } + out.append('%'); + return i + 1; + } + + if (i >= length || CONVERSION_CHARS.indexOf(format.charAt(i)) < 0) { + // Unknown or unterminated conversion: print the specifier + // verbatim (including the offending character) without + // consuming an argument, like gawk. An explicit position + // still pins the format to positional mode, also like gawk. + if (argPosition > 0) { + recordArgumentMode(true); + requireArgumentIndex(argPosition); + } + int end = i < length ? i + 1 : length; + out.append(format, start, end); + return end; + } + + char conversion = format.charAt(i); + i++; + + Flags flags = new Flags(leftJustify, plusSign, spaceSign, zeroPad, alternate, grouping); + recordArgumentMode(argPosition > 0); + Object arg = argPosition > 0 ? argAt(argPosition) : nextArg(); + render(conversion, flags, width, precision, arg); + return i; + } + + /** + * Rejects digits after a star operand that are not terminated by + * {@code $}, like gawk: {@code %*2d} is fatal rather than literal. + */ + private void requireStarPositionTerminated(int i, int starArgEnd) { + if (starArgEnd == i && i < format.length() && isAsciiDigit(format.charAt(i))) { + throw new AwkRuntimeException( + "no `$' supplied for positional field width or precision in `" + format + "'"); + } + } + + /** + * Returns the index right after a {@code n$} sequence starting at + * {@code i}, or {@code i} when there is no such sequence. + */ + private int starPositionEnd(int i) { + int length = format.length(); + int digitsEnd = i; + while (digitsEnd < length && isAsciiDigit(format.charAt(digitsEnd))) { + digitsEnd++; + } + if (digitsEnd > i && digitsEnd < length && format.charAt(digitsEnd) == '$') { + return digitsEnd + 1; + } + return i; + } + + private Object nextArg() { + if (argIndex >= args.length) { + throw new AwkRuntimeException("not enough arguments to satisfy format string `" + format + "'"); + } + return args[argIndex++]; + } + + private Object argAt(int position) { + requireArgumentIndex(position); + return args[position - 1]; + } + + /** + * Validates a positional ({@code n$}) argument index against the + * supplied arguments, like gawk, which checks the index even for + * conversions that do not consume the referenced value. + */ + private void requireArgumentIndex(int position) { + if (position <= 0) { + throw new AwkRuntimeException("argument index with `$' must be > 0 in `" + format + "'"); + } + if (position > args.length) { + throw new AwkRuntimeException( + "argument index " + position + " greater than total number of supplied arguments in `" + + format + "'"); + } + } + + /** + * Records how one conversion selects its value argument and rejects + * format strings that mix positional ({@code n$}) and sequential + * conversions, like gawk. Star width and precision operands are not + * tracked: gawk allows an explicitly positioned star operand + * ({@code %*2$s}) alongside sequential conversions. + * + * @param positional whether the conversion used an {@code n$} index + */ + private void recordArgumentMode(boolean positional) { + if (positional) { + sawPositional = true; + } else { + sawSequential = true; + } + if (sawPositional && sawSequential) { + throw new AwkRuntimeException("must use `count$' on all formats or none in `" + format + "'"); + } + } + + private void render(char conversion, Flags flags, int width, int precision, Object arg) { + switch (conversion) { + case 'c': + appendPadded(characterOf(arg), flags.leftJustify, false, width); + break; + case 's': + String s = toAwkString(arg, convfmt, locale); + if (precision >= 0 && s.codePointCount(0, s.length()) > precision) { + // The precision counts characters (code points), so it + // can never split a surrogate pair. + s = s.substring(0, s.offsetByCodePoints(0, precision)); + } + appendPadded(s, flags.leftJustify, false, width); + break; + case 'd': + case 'i': + renderSignedInteger(flags, width, precision, arg); + break; + case 'u': + case 'o': + case 'x': + case 'X': + renderUnsignedInteger(conversion, flags, width, precision, arg); + break; + case 'e': + case 'E': + case 'f': + case 'F': + case 'g': + case 'G': + case 'a': + case 'A': + renderFloat(conversion, flags, width, precision, arg); + break; + default: + // Unreachable: the caller only passes known conversions. + break; + } + } + + /** Renders the {@code %c} character for the given argument. */ + private String characterOf(Object arg) { + if (arg == null) { + return NUL_STRING; + } + boolean numeric = arg instanceof Number || (arg instanceof StrNum && ((StrNum) arg).isNumber()); + if (numeric) { + long code = (long) JRT.toDouble(arg); + StringBuilder sb = new StringBuilder(2); + if (code >= 0 && code <= Character.MAX_CODE_POINT) { + sb.appendCodePoint((int) code); + } else { + sb.append((char) code); + } + return sb.toString(); + } + String s = arg.toString(); + if (s.isEmpty()) { + return NUL_STRING; + } + StringBuilder sb = new StringBuilder(2); + sb.appendCodePoint(s.codePointAt(0)); + return sb.toString(); + } + + private void renderSignedInteger(Flags flags, int width, int precision, Object arg) { + double d = JRT.toDouble(arg); + if (renderNonFinite('d', flags, width, d)) { + return; + } + + boolean negative; + String magnitude; + if (arg instanceof Long || arg instanceof Integer || arg instanceof Short || arg instanceof Byte) { + long v = ((Number) arg).longValue(); + negative = v < 0; + magnitude = negative ? Long.toUnsignedString(-v) : Long.toString(v); + } else if (d >= -TWO_POW_63 && d < TWO_POW_63) { + long v = (long) d; + negative = v < 0; + magnitude = negative ? Long.toUnsignedString(-v) : Long.toString(v); + } else { + // Out of 64-bit range: print the full decimal expansion of the + // (integral) double, like gawk. + // The exact binary value of the double is intended: it makes rounding match gawk's C library. + BigInteger bi = new BigDecimal(d).toBigInteger(); // NOPMD + negative = bi.signum() < 0; + magnitude = bi.abs().toString(); + } + + String sign = negative ? "-" : flags.plusSign ? "+" : flags.spaceSign ? " " : ""; + appendInteger(sign, "", magnitude, flags, width, precision, isZeroMagnitude(magnitude)); + } + + private void renderUnsignedInteger(char conversion, Flags flags, int width, int precision, Object arg) { + double d = JRT.toDouble(arg); + if (renderNonFinite(conversion, flags, width, d)) { + return; + } + + int radix = conversion == 'o' ? 8 : conversion == 'u' ? 10 : 16; + String magnitude; + if (arg instanceof Long || arg instanceof Integer || arg instanceof Short || arg instanceof Byte) { + magnitude = Long.toUnsignedString(((Number) arg).longValue(), radix); + } else if (d >= -TWO_POW_63 && d < TWO_POW_63) { + magnitude = Long.toUnsignedString((long) d, radix); + } else { + // The exact binary value of the double is intended: it makes rounding match gawk's C library. + BigInteger bi = new BigDecimal(d).toBigInteger(); // NOPMD + if (bi.signum() >= 0 && bi.compareTo(TWO_POW_64) < 0) { + magnitude = bi.toString(radix); + } else { + // Out of the unsigned 64-bit range: fall back to %g + // notation with the original sign, flags, precision, and + // width, like gawk. + renderFloat('g', flags, width, precision, Double.valueOf(d)); + return; + } + } + if (conversion == 'X') { + magnitude = magnitude.toUpperCase(Locale.ROOT); + } + + boolean zeroValue = d == 0; + boolean zeroMagnitude = isZeroMagnitude(magnitude); + int actualPrecision = precision; + if (zeroMagnitude && precision == 0 && (flags.alternate || !zeroValue)) { + // gawk prints "0" rather than nothing for a zero magnitude + // with an explicit zero precision when the '#' flag is given, + // or when the original value is nonzero and merely truncates + // to zero. + actualPrecision = 1; + } + // The '#' prefix depends on the original value, not the truncated + // magnitude: gawk prints "0x0" for %#.0x with 0.1. For %o, gawk + // always adds the alternate leading zero in addition to any + // precision padding: %#.5o of 1 prints "000001". + String prefix = ""; + if (flags.alternate && !zeroValue) { + if (conversion == 'x') { + prefix = "0x"; + } else if (conversion == 'X') { + prefix = "0X"; + } else if (conversion == 'o') { + prefix = "0"; + } + } + // gawk's ' flag groups decimal output only, never octal or + // hexadecimal. + Flags integerFlags = conversion == 'u' ? flags : flags.withoutGrouping(); + appendInteger("", prefix, magnitude, integerFlags, width, actualPrecision, zeroMagnitude); + } + + /** + * Applies precision, grouping, and width to an integer body and + * appends it to the output. + */ + private void appendInteger( + String sign, + String prefix, + String magnitude, + Flags flags, + int width, + int precision, + boolean zeroMagnitude) { + String digits = magnitude; + if (precision == 0 && zeroMagnitude) { + // C: a zero value with an explicit zero precision prints no + // characters. gawk drops the sign flags as well. + appendPadded("", flags.leftJustify, false, width); + return; + } + if (precision > digits.length()) { + digits = zeros(precision - digits.length()) + digits; + } + if (flags.grouping) { + digits = groupDigits(digits); + } + String body = sign + prefix + digits; + if (width > body.length() && flags.zeroPad && !flags.leftJustify && precision < 0) { + // Zero padding goes between the sign/prefix and the digits. + out.append(sign).append(prefix); + out.append(zeros(width - body.length())); + out.append(digits); + return; + } + appendPadded(body, flags.leftJustify, false, width); + } + + private void renderFloat(char conversion, Flags flags, int width, int precision, Object arg) { + double d = JRT.toDouble(arg); + if (renderNonFinite(conversion, flags, width, d)) { + return; + } + String magnitude = floatBody(conversion, flags, precision, d); + if (magnitude == null) { + return; + } + boolean negative = d < 0 || (d == 0 && Double.doubleToRawLongBits(d) != 0L); + String sign = negative ? "-" : flags.plusSign ? "+" : flags.spaceSign ? " " : ""; + // The hexadecimal prefix of %a/%A stays ahead of any zero padding, + // like an integer prefix. + String prefix = ""; + if (magnitude.startsWith("0x") || magnitude.startsWith("0X")) { + prefix = magnitude.substring(0, 2); + magnitude = magnitude.substring(2); + } + String full = sign + prefix + magnitude; + if (width > full.length() && flags.zeroPad && !flags.leftJustify) { + out.append(sign).append(prefix); + out.append(zeros(width - full.length())); + out.append(magnitude); + return; + } + appendPadded(full, flags.leftJustify, false, width); + } + + /** + * Renders the digits of a finite double for a floating-point + * conversion, without sign and without width padding: the absolute + * value is formatted and the caller applies the sign. + */ + private String floatBody(char conversion, Flags flags, int precision, double d) { + double abs = Math.abs(d); + switch (conversion) { + case 'f': + case 'F': { + int p = precision < 0 ? 6 : precision; + // The exact binary value of the double is intended: it makes rounding match gawk's C library. + String s = decimalString(new BigDecimal(abs).setScale(p, RoundingMode.HALF_EVEN)); // NOPMD + if (flags.alternate && p == 0) { + s = forceDecimalSeparator(s); + } + if (flags.grouping) { + s = groupDigits(s); + } + return s; + } + case 'e': + case 'E': { + int p = precision < 0 ? 6 : precision; + String s = scientific(abs, p); + if (flags.alternate && p == 0) { + int exponentStart = s.indexOf('e'); + s = forceDecimalSeparator(s.substring(0, exponentStart)) + s.substring(exponentStart); + } + return conversion == 'E' ? s.toUpperCase(Locale.ROOT) : s; + } + case 'g': + case 'G': { + int p = precision < 0 ? 6 : precision == 0 ? 1 : precision; + String s = generalFloat(abs, p, flags.alternate, flags.grouping); + return conversion == 'G' ? s.toUpperCase(Locale.ROOT) : s; + } + case 'a': + case 'A': + default: { + // %a is C-library dependent in gawk; delegate to Java's + // hexadecimal float notation. + StringBuilder spec = new StringBuilder("%"); + if (precision >= 0) { + spec.append('.').append(precision); + } + spec.append(conversion); + try { + String s = String.format(locale, spec.toString(), Double.valueOf(abs)); + return s; + } catch (IllegalFormatException e) { + out.append(spec); + return null; + } + } + } + } + + /** Formats {@code abs >= 0} in C's {@code %e} notation. */ + private String scientific(double abs, int precision) { + BigDecimal mantissa; + int exponent; + if (abs == 0) { + mantissa = BigDecimal.ZERO.setScale(precision); + exponent = 0; + } else { + // The exact binary value of the double is intended: this is what makes + // rounding match the C library used by gawk. + BigDecimal rounded = new BigDecimal(abs) // NOPMD + .round(new MathContext(precision + 1, RoundingMode.HALF_EVEN)); + exponent = rounded.precision() - rounded.scale() - 1; + mantissa = rounded.movePointLeft(exponent).setScale(precision, RoundingMode.UNNECESSARY); + } + return decimalString(mantissa) + "e" + (exponent < 0 ? "-" : "+") + exponentDigits(Math.abs(exponent)); + } + + /** Formats {@code abs >= 0} in C's {@code %g} notation. */ + private String generalFloat(double abs, int precision, boolean alternate, boolean grouping) { + if (abs == 0) { + return alternate ? forceDecimalSeparator("0") + zeros(precision - 1) : "0"; + } + // The exact binary value of the double is intended: it makes rounding match gawk's C library. + BigDecimal rounded = new BigDecimal(abs).round(new MathContext(precision, RoundingMode.HALF_EVEN)); // NOPMD + int exponent = rounded.precision() - rounded.scale() - 1; + if (exponent >= -4 && exponent < precision) { + String s = decimalString(rounded.setScale(precision - 1 - exponent, RoundingMode.UNNECESSARY)); + s = alternate ? forceDecimalSeparator(s) : stripTrailingFractionZeros(s); + // gawk groups %g in fixed notation, like %f, but never in + // exponential notation. + return grouping ? groupDigits(s) : s; + } + String mantissa = decimalString( + rounded.movePointLeft(exponent).setScale(precision - 1, RoundingMode.UNNECESSARY)); + mantissa = alternate ? forceDecimalSeparator(mantissa) : stripTrailingFractionZeros(mantissa); + return mantissa + "e" + (exponent < 0 ? "-" : "+") + exponentDigits(Math.abs(exponent)); + } + + /** + * Renders NaN and infinities for any numeric conversion, honoring the + * sign flags and field width, and returns {@code true} when the value + * was such a special value. + */ + private boolean renderNonFinite(char conversion, Flags flags, int width, double d) { + if (!Double.isNaN(d) && !Double.isInfinite(d)) { + return false; + } + String body; + if (Double.isNaN(d)) { + body = flags.plusSign ? "+nan" : flags.spaceSign ? " nan" : "nan"; + } else if (d > 0) { + body = flags.plusSign ? "+inf" : flags.spaceSign ? " inf" : "inf"; + } else { + body = "-inf"; + } + if (isUpperCaseConversion(conversion)) { + body = body.toUpperCase(Locale.ROOT); + } + // The zero flag is ignored for non-finite values, like C. + appendPadded(body, flags.leftJustify, false, width); + return true; + } + + /** Renders a {@link BigDecimal} using the locale's decimal separator. */ + private String decimalString(BigDecimal value) { + String s = value.toPlainString(); + char decimalSeparator = DecimalFormatSymbols.getInstance(locale).getDecimalSeparator(); + return decimalSeparator == '.' ? s : s.replace('.', decimalSeparator); + } + + /** Inserts locale grouping separators into the integer part of {@code s}. */ + private String groupDigits(String s) { + char groupingSeparator = DecimalFormatSymbols.getInstance(locale).getGroupingSeparator(); + char decimalSeparator = DecimalFormatSymbols.getInstance(locale).getDecimalSeparator(); + int end = s.indexOf(decimalSeparator); + if (end < 0) { + end = s.length(); + } + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < end; i++) { + sb.append(s.charAt(i)); + int remaining = end - 1 - i; + if (remaining > 0 && remaining % 3 == 0 && isAsciiDigit(s.charAt(i))) { + sb.append(groupingSeparator); + } + } + sb.append(s, end, s.length()); + return sb.toString(); + } + + /** + * Appends the locale decimal separator when {@code s} has none, as + * the '#' flag requires for {@code %g} results without fractional + * digits. + */ + private String forceDecimalSeparator(String s) { + char decimalSeparator = DecimalFormatSymbols.getInstance(locale).getDecimalSeparator(); + return s.indexOf(decimalSeparator) < 0 ? s + decimalSeparator : s; + } + + private String stripTrailingFractionZeros(String s) { + char decimalSeparator = DecimalFormatSymbols.getInstance(locale).getDecimalSeparator(); + if (s.indexOf(decimalSeparator) < 0) { + return s; + } + int end = s.length(); + while (end > 0 && s.charAt(end - 1) == '0') { + end--; + } + if (end > 0 && s.charAt(end - 1) == decimalSeparator) { + end--; + } + return s.substring(0, end); + } + + private void appendPadded(String body, boolean leftJustify, boolean zeroPad, int width) { + // The field width counts characters (code points), so that a + // supplementary character fills one column, not two. + int bodyLength = body.codePointCount(0, body.length()); + if (width <= bodyLength) { + out.append(body); + return; + } + int padLength = width - bodyLength; + if (leftJustify) { + out.append(body); + appendSpaces(padLength); + } else if (zeroPad) { + out.append(zeros(padLength)).append(body); + } else { + appendSpaces(padLength); + out.append(body); + } + } + + private void appendSpaces(int count) { + for (int i = 0; i < count; i++) { + out.append(' '); + } + } + } + + /** Renders an exponent value with at least two digits, like C. */ + private static String exponentDigits(int exponent) { + String digits = Integer.toString(exponent); + return digits.length() < 2 ? "0" + digits : digits; + } + + private static boolean isUpperCaseConversion(char conversion) { + return conversion == 'X' || conversion == 'E' || conversion == 'F' || conversion == 'G' || conversion == 'A'; + } + + private static boolean isZeroMagnitude(String magnitude) { + for (int i = 0; i < magnitude.length(); i++) { + if (magnitude.charAt(i) != '0') { + return false; + } + } + return true; + } + + private static boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static int parseInt(String s, int from, int to) { + long value = 0; + for (int i = from; i < to; i++) { + value = value * 10 + s.charAt(i) - '0'; + if (value > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + } + return (int) value; + } + + private static String zeros(int count) { + StringBuilder sb = new StringBuilder(Math.max(count, 0)); + for (int i = 0; i < count; i++) { + sb.append('0'); + } + return sb.toString(); + } +} diff --git a/src/main/java/io/jawk/jrt/AwkSink.java b/src/main/java/io/jawk/jrt/AwkSink.java index 006ca530..1cd11b01 100644 --- a/src/main/java/io/jawk/jrt/AwkSink.java +++ b/src/main/java/io/jawk/jrt/AwkSink.java @@ -27,7 +27,6 @@ import java.io.PrintStream; import java.math.BigDecimal; import java.util.Locale; -import org.metricshub.printf4j.Printf4J; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** @@ -85,11 +84,13 @@ public final Locale getLocale() { * @param ofs output field separator * @param ors output record separator * @param ofmt numeric output format available to the sink + * @param convfmt number-to-string conversion format ({@code CONVFMT}), + * used by {@code %s} to convert numeric values the way AWK does * @param format format string passed to {@code printf} * @param values arguments supplied after the format string * @throws IOException if the sink cannot write the output */ - public abstract void printf(String ofs, String ors, String ofmt, String format, Object... values) + public abstract void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) throws IOException; /** @@ -124,7 +125,7 @@ public PrintStream getPrintStream() { *

* This singleton is safe to share across all JRT/AVM instances because * its {@link #print(String, String, String, Object...)}, - * {@link #printf(String, String, String, String, Object...)}, and + * {@link #printf(String, String, String, String, String, Object...)}, and * {@link #flush()} operations are all no-ops. */ public static final AwkSink NOP_SINK = new NoOpAwkSink(); @@ -141,7 +142,7 @@ public void print(String ofs, String ors, String ofmt, Object... values) { } @Override - public void printf(String ofs, String ors, String ofmt, String format, Object... values) { + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { // discard } } @@ -282,33 +283,25 @@ protected final Object normalizePrintArgument(Object value) { } /** - * Formats a string in the same way as AWK's {@code sprintf()} built-in. + * Formats a string in the same way as AWK's {@code sprintf()} built-in, + * converting numeric {@code %s} operands with the supplied {@code CONVFMT} + * value. *

* Subclasses may override this method to customize formatting. The default - * implementation delegates to {@link Printf4J#sprintf(Locale, String, Object...)}. - * Because {@link #printf(String, String, String, String, Object...)} uses this - * method internally, overriding it ensures that both {@code printf} and - * {@code sprintf} produce consistent output. + * implementation delegates to + * {@link AwkPrintf#sprintf(Locale, String, String, Object...)}. The + * built-in sinks render {@code printf} output through this method, so + * overriding it keeps {@code printf} and {@code sprintf} consistent. *

* + * @param convfmt number-to-string conversion format ({@code CONVFMT}) * @param format format string * @param values arguments supplied after the format string * @return formatted text */ - public String sprintf(String format, Object... values) { + public String sprintf(String convfmt, String format, Object... values) { Object[] safeValues = values == null ? new Object[0] : values; - return Printf4J.sprintf(locale, format, safeValues); - } - - /** - * Formats one {@code printf} result string using this sink's locale. - * - * @param format format string passed to {@code printf} - * @param values arguments supplied after the format string - * @return formatted text - */ - protected final String formatPrintfResult(String format, Object... values) { - return sprintf(format, values); + return AwkPrintf.sprintf(locale, convfmt, format, safeValues); } /** @@ -320,33 +313,6 @@ protected final String formatPrintfResult(String format, Object... values) { * @return textual output for {@code value} */ public static String formatOutputValue(Object value, String ofmt, Locale locale) { - if (value == null) { - return ""; - } - if (!(value instanceof Number)) { - return value.toString(); - } - - double number = ((Number) value).doubleValue(); - if (JRT.isActuallyLong(number)) { - return Long.toString((long) Math.rint(number)); - } - - try { - String rendered = String.format(locale, ofmt, number); - if ((rendered.indexOf('.') > -1 || rendered.indexOf(',') > -1) - && rendered.indexOf('e') == -1 - && rendered.indexOf('E') == -1) { - while (rendered.endsWith("0")) { - rendered = rendered.substring(0, rendered.length() - 1); - } - if (rendered.endsWith(".") || rendered.endsWith(",")) { - rendered = rendered.substring(0, rendered.length() - 1); - } - } - return rendered; - } catch (java.util.UnknownFormatConversionException e) { - return ""; - } + return AwkPrintf.toAwkString(value, ofmt, locale); } } diff --git a/src/main/java/io/jawk/jrt/JRT.java b/src/main/java/io/jawk/jrt/JRT.java index 2337f735..a4395f49 100644 --- a/src/main/java/io/jawk/jrt/JRT.java +++ b/src/main/java/io/jawk/jrt/JRT.java @@ -662,6 +662,45 @@ public static boolean isActuallyLong(double d) { return Math.abs(d - r) < Math.ulp(d); } + /** 2^63 as a double: the first value beyond the signed 64-bit range. */ + private static final double TWO_POW_63 = 9.223372036854775808e18; + + /** + * Converts a computed double to the canonical AWK scalar: a {@link Long} + * when the value is integral and representable as a signed 64-bit integer, + * and the {@link Double} itself otherwise. Values beyond the 64-bit range + * stay doubles so they are not silently saturated to + * {@link Long#MAX_VALUE}. + * + * @param d the computed value + * @return {@code d} as a {@link Long} when exactly representable, or as a + * {@link Double} + */ + public static Object toScalarNumber(double d) { + if (isActuallyLong(d)) { + double rounded = Math.rint(d); + if (rounded >= -TWO_POW_63 && rounded < TWO_POW_63) { + return Long.valueOf((long) rounded); + } + } + return Double.valueOf(d); + } + + /** + * Truncates a double toward zero, as AWK's {@code int()} does, returning a + * {@link Long} when the result is representable and a {@link Double} + * otherwise. + * + * @param d the value to truncate + * @return the truncated value as a canonical AWK scalar + */ + public static Object truncateToScalar(double d) { + if (Double.isNaN(d) || Double.isInfinite(d)) { + return Double.valueOf(d); + } + return toScalarNumber(d < 0 ? Math.ceil(d) : Math.floor(d)); + } + /** * Convert a String, Long, or Double to Long. * @@ -855,10 +894,7 @@ public static Object toJavaScalar(Object value) { return value.toString(); } if (value instanceof Double || value instanceof Float) { - double number = ((Number) value).doubleValue(); - if (isActuallyLong(number)) { - return Long.valueOf((long) Math.rint(number)); - } + return toScalarNumber(((Number) value).doubleValue()); } return value; } @@ -2484,7 +2520,20 @@ public void printToProcess(String cmd, Object[] values) throws IOException { * @throws IOException if the sink cannot be written to */ public void printfDefault(String format, Object[] values) throws IOException { - awkSink.printf(ofs, ors, ofmt, format, values); + awkSink.printf(ofs, ors, ofmt, convfmt, format, values); + } + + /** + * Formats a string in the same way as AWK's {@code sprintf()} built-in, + * through the default output sink and with the current {@code CONVFMT} + * value. + * + * @param format format string passed to {@code sprintf} + * @param values arguments supplied after the format string + * @return formatted text + */ + public String sprintf(String format, Object... values) { + return awkSink.sprintf(convfmt, format, values); } /** @@ -2499,7 +2548,7 @@ public void printfDefault(String format, Object[] values) throws IOException { public void printfToFile(String fileNameParam, boolean append, String format, Object[] values) throws IOException { AwkSink sink = getFileAwkSink(fileNameParam, append); - sink.printf(ofs, ors, ofmt, format, values); + sink.printf(ofs, ors, ofmt, convfmt, format, values); } /** @@ -2512,7 +2561,7 @@ public void printfToFile(String fileNameParam, boolean append, String format, Ob */ public void printfToProcess(String cmd, String format, Object[] values) throws IOException { AwkSink sink = getPipeAwkSink(cmd); - sink.printf(ofs, ors, ofmt, format, values); + sink.printf(ofs, ors, ofmt, convfmt, format, values); sink.flush(); } diff --git a/src/main/java/io/jawk/jrt/OutputStreamAwkSink.java b/src/main/java/io/jawk/jrt/OutputStreamAwkSink.java index 7b35a67e..1047d125 100644 --- a/src/main/java/io/jawk/jrt/OutputStreamAwkSink.java +++ b/src/main/java/io/jawk/jrt/OutputStreamAwkSink.java @@ -98,8 +98,8 @@ public void print(String ofs, String ors, String ofmt, Object... values) { } @Override - public void printf(String ofs, String ors, String ofmt, String format, Object... values) { - printStream.print(formatPrintfResult(format, values)); + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { + printStream.print(sprintf(convfmt, format, values)); } @Override diff --git a/src/site/markdown/behavior-changes.md b/src/site/markdown/behavior-changes.md index 80230edb..21ecac35 100644 --- a/src/site/markdown/behavior-changes.md +++ b/src/site/markdown/behavior-changes.md @@ -20,7 +20,44 @@ released version automatically via .github/scripts/stamp-behavior-changes.sh. ## Unreleased -_No user-visible behavior changes recorded yet._ +- `printf` and `sprintf` are now implemented natively with POSIX AWK / gawk semantics instead of + delegating to the Printf4J library, which emulated glibc's `printf()` + ([#528](https://github.com/jawkio/jawk/issues/528)): + - `%s` converts numeric values with AWK's number-to-string rules: integral values print + without a fractional part (`printf "%s", i` after `i++` now prints `1`, not `1.0`), and + non-integral values honor the script's current `CONVFMT` value. + - `%c` prints the character for a numeric code point (`printf "%c", 65` prints `A`, + previously `A` only for literal numbers, not for numeric strings or fields), or the first + character of a string value. + - Dynamic precision (`%.*f`) is now supported in addition to dynamic width (`%*d`), including + negative values (negative width left-justifies, negative precision means no precision), as + are gawk positional specifiers (`%2$s`) and the `'` grouping flag (`%'d`); mixing + positional and sequential specifiers in one format string is a fatal error, as in gawk. + - Out-of-range integer conversions follow gawk: negative values wrap to unsigned 64-bit for + `%u`/`%o`/`%x`/`%X`, values beyond 64 bits print the full decimal expansion for `%d`/`%i` + and fall back to `%g` notation for `%u`/`%o`/`%x`/`%X`. + - NaN and infinities print as `nan`, `inf`, and `-inf` (previously Java's `NaN` / + `Infinity`), in `print`, `printf`, and number-to-string conversions. + - `%e`, `%f`, and `%g` round halfway cases to even like the C library used by gawk + (`printf "%.0f", 2.5` prints `2`, previously `3`), and `%g` strips trailing zeros before + padding (previously only when no padding applied). + - `printf` with too few arguments is now a fatal error, as in gawk (previously the leftover + specifiers were printed verbatim). + - Unknown conversion specifiers (including `%n`, which Printf4J turned into a newline, and + invalid length modifiers such as `ll` or `hh`) are printed verbatim without consuming an + argument, as in gawk; the `h`, `j`, `l`, `L`, `t`, and `z` length modifiers are each + accepted at most once and ignored. +- Integral values beyond the 64-bit range are no longer saturated to 2^63-1: `print 2^100` now + prints the full decimal expansion `1267650600228229401496703205376` (previously + `9223372036854775807`), and `int()` preserves such values + ([#528](https://github.com/jawkio/jawk/issues/528)). +- Breaking change for Java embedders: `AwkSink.printf(...)` now receives the script's current + `CONVFMT` value as a parameter (between `ofmt` and `format`), just like it already received + `OFMT`, and `AwkSink.sprintf(...)` now takes `CONVFMT` as its first parameter + (`sprintf(convfmt, format, values...)`). Custom sinks must be updated to the new signatures; + overriding `sprintf` still customizes both `printf` and `sprintf` output. The + `org.metricshub:printf4j` dependency has been removed; its formatting logic now lives in + `io.jawk.jrt.AwkPrintf` ([#528](https://github.com/jawkio/jawk/issues/528)). ## [v7.0.01](https://github.com/jawkio/jawk/releases/tag/v7.0.01) (2026-07-31) diff --git a/src/site/markdown/compatibility.md.vm b/src/site/markdown/compatibility.md.vm index 8b1ff339..63d5ab84 100644 --- a/src/site/markdown/compatibility.md.vm +++ b/src/site/markdown/compatibility.md.vm @@ -188,6 +188,29 @@ The date and time functions (`mktime()`, `strftime()`) follow the Java platform' - A positive `mktime()` DST hint applies the zone's current daylight adjustment; zones without daylight saving time ignore the hint. - `strftime()`'s `%Z` prints the zone's current designation (the JDK's time zone data records historical offsets and DST rules, but not historical zone names), and timestamps before the common era use the JDK's year numbering rather than astronomical (negative) years. +${esc.h}${esc.h}${esc.h} printf and sprintf formatting + +`printf` and `sprintf` implement the POSIX AWK conversions with gawk's semantics: `%s` converts +numbers with `CONVFMT` (integral values print without a fractional part), `%c` prints the +character of a numeric code point or the first character of a string, `%i` is an alias for `%d`, +dynamic `*` width and precision consume arguments (a negative width left-justifies, a negative +precision means no precision), gawk positional specifiers (`%2$s`) and the `'` grouping flag are +honored, out-of-range integers wrap or fall back exactly as in gawk, halfway cases round to even +(`printf "%.0f", 2.5` prints `2`), too few arguments is a fatal error, and unknown conversion +specifiers print verbatim without consuming an argument. + +A few edge cases follow Java's platform rules rather than the C library's: + +- `%c` is locale-independent: a numeric argument selects the Unicode code point (so + `printf "%c", 233` always prints `é`), where C-locale gawk emits the raw byte. Values that are + not valid code points are truncated to a UTF-16 char. Field widths and `%s` precision count + characters (code points) regardless of locale, like gawk in a multibyte locale — except that + gawk pads `%c` by bytes, which Jawk does not reproduce. +- `%a`/`%A` use Java's hexadecimal floating-point notation (`0x1.0p0` where glibc prints + `0x1p+0`); gawk itself documents these conversions as C-library dependent. +- NaN always prints as `nan`: Java does not track the sign of NaN, so gawk's occasional `-nan` + is rendered without a sign. + ${esc.h}${esc.h}${esc.h} Range patterns Range patterns (`begpat, endpat`) evaluate their two conditions lazily, as POSIX requires: the start condition is evaluated only while outside the range, and the end condition only once the range has started — including on the very record that starts it, so a range can begin and end on the same record. Conditions with side effects, such as `a++ == 2, a++ == 5`, therefore behave exactly as in gawk and One True Awk: each condition's side effects run only on the records where that condition is actually tested. diff --git a/src/site/markdown/index.md.vm b/src/site/markdown/index.md.vm index 47dd07de..2e27790b 100644 --- a/src/site/markdown/index.md.vm +++ b/src/site/markdown/index.md.vm @@ -119,7 +119,7 @@ Differences with Traditional AWK Jawk aims to be a practical AWK implementation for JVM environments, but it is not a byte-for-byte clone of every historical AWK behavior. Some differences are deliberate and come from the way Jawk integrates with Java: - Regular expression behavior follows Java's regex engine, which may differ from traditional AWK regexes in edge cases. Notably, alternation picks the first matching branch rather than the POSIX longest one, which can affect `match()`, field splitting with `patsplit()`, and similar content-driven matching: order alternatives longest-first. -- `printf()` and `sprintf()` try to replicate C-style formatting but may have differences due to Java's formatting capabilities and limitations. +- `printf()` and `sprintf()` implement the POSIX AWK conversions with gawk's semantics, including `CONVFMT`-based `%s` conversion, dynamic `*` width and precision, and gawk's out-of-range integer handling; only a few edge cases (`%a` notation, `%c` locale independence, the sign of NaN) follow Java's platform rules. - Some floating-point edge cases may differ due to Java's handling of floating-point arithmetic and representation. - Jawk resolves user-defined function calls during compilation. It does not defer all of that work to runtime. - The date and time functions (`mktime()`, `strftime()`) follow the Java platform's time zone data and calendar rules rather than the C library's, which differs from gawk in a few edge cases. diff --git a/src/site/markdown/java-output.md b/src/site/markdown/java-output.md index 173b8580..fe8ef66f 100644 --- a/src/site/markdown/java-output.md +++ b/src/site/markdown/java-output.md @@ -66,7 +66,7 @@ public final class CollectingSink extends AwkSink { } @Override - public void printf(String ofs, String ors, String ofmt, String format, Object... values) { + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { // store format + values however your application wants } @@ -100,6 +100,7 @@ public final class CollectingSink extends AwkSink { > | `ofs` | `OFS` | Output Field Separator, inserted between values | > | `ors` | `ORS` | Output Record Separator, appended after the record | > | `ofmt` | `OFMT` | Default numeric output format | +> | `convfmt` | `CONVFMT` | Number-to-string conversion format used by `%s` | > | `format` | — | The AWK format string | > | `values` | — | The AWK values to be formatted | diff --git a/src/test/java/io/jawk/AwkTest.java b/src/test/java/io/jawk/AwkTest.java index 056b7ea0..4067d9e9 100644 --- a/src/test/java/io/jawk/AwkTest.java +++ b/src/test/java/io/jawk/AwkTest.java @@ -2052,7 +2052,7 @@ public void print(String ofs, String ors, String ofmt, Object... values) { } @Override - public void printf(String ofs, String ors, String ofmt, String format, Object... values) { + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { printfFormats.add(format); printfValues.add(Arrays.asList(Arrays.copyOf(values, values.length))); } diff --git a/src/test/java/io/jawk/PosixConformanceTest.java b/src/test/java/io/jawk/PosixConformanceTest.java index aa8cc54e..3d5004d3 100644 --- a/src/test/java/io/jawk/PosixConformanceTest.java +++ b/src/test/java/io/jawk/PosixConformanceTest.java @@ -685,7 +685,6 @@ public void posix93PrintfPercentCUsesFirstCharacter() throws Exception { @Test public void posix94PrintfStarWidthPrecision() throws Exception { - Assume.assumeTrue("Dynamic width/precision in printf requires printf4j support", false); AwkTestSupport .awkTest("POSIX 9.4 printf star width and precision") .script("BEGIN{ printf \"%*.*f\\n\", 6, 2, 3.14159 }") diff --git a/src/test/java/io/jawk/PrintfTest.java b/src/test/java/io/jawk/PrintfTest.java new file mode 100644 index 00000000..d50c677a --- /dev/null +++ b/src/test/java/io/jawk/PrintfTest.java @@ -0,0 +1,220 @@ +package io.jawk; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import io.jawk.jrt.AwkRuntimeException; +import org.junit.Test; + +/** + * Script-level tests for AWK {@code printf}/{@code sprintf} semantics + * (issue #528), matching gawk behavior. + */ +public class PrintfTest { + + @Test + public void testStringConversionOfIntegralDouble() throws Exception { + // The symptom reported in issue #528: i++ produces a double, and %s + // must print its integral value without a fractional part. + AwkTestSupport + .awkTest("printf %s prints integral doubles without fraction") + .script("BEGIN { a[0]=1; a[1]=2; for (i=0; i in a; i++) printf(\"x[%s]\\n\", i) }") + .expectLines("x[0]", "x[1]") + .runAndAssert(); + } + + @Test + public void testStringConversionHonorsConvfmt() throws Exception { + AwkTestSupport + .awkTest("printf %s converts numbers with CONVFMT") + .script("BEGIN { CONVFMT=\"%.2g\"; printf \"%s|\", 3.14159; s = sprintf(\"%s\", 3.14159); print s }") + .expectLines("3.1|3.1") + .runAndAssert(); + } + + @Test + public void testOfmtDoesNotAffectPrintf() throws Exception { + AwkTestSupport + .awkTest("printf %s ignores OFMT") + .script("BEGIN { OFMT=\"%.2f\"; printf \"%s\\n\", 3.14159 }") + .expectLines("3.14159") + .runAndAssert(); + } + + @Test + public void testCharConversionOfNumericValue() throws Exception { + AwkTestSupport + .awkTest("printf %c prints the character of a numeric code") + .script("BEGIN { printf \"%c%c\\n\", 65, 98.7 }") + .expectLines("Ab") + .runAndAssert(); + } + + @Test + public void testCharConversionOfNumericField() throws Exception { + AwkTestSupport + .awkTest("printf %c treats numeric fields as codes") + .script("{ printf \"%c\\n\", $1 }") + .stdin("65\n") + .expectLines("A") + .runAndAssert(); + } + + @Test + public void testDynamicPrecision() throws Exception { + AwkTestSupport + .awkTest("printf dynamic star width and precision") + .script("BEGIN { printf \"%.*s|%*d|%-*d|\\n\", 3, \"foobar\", 5, 42, 5, 42 }") + .expectLines("foo| 42|42 |") + .runAndAssert(); + } + + @Test + public void testIntegerConversions() throws Exception { + AwkTestSupport + .awkTest("printf integer conversions truncate and wrap like gawk") + .script("BEGIN { printf \"%d|%d|%i|%u|%x|%o\\n\", 42.7, -42.7, \"1e3\", -1, -1, 8 }") + .expectLines("42|-42|1000|18446744073709551615|ffffffffffffffff|10") + .runAndAssert(); + } + + @Test + public void testOutOfRangeIntegerConversions() throws Exception { + AwkTestSupport + .awkTest("printf out-of-range integers match gawk") + .script("BEGIN { printf \"%d|%d|%x\\n\", 2^100, 2^63, 2^100 }") + .expectLines("1267650600228229401496703205376|9223372036854775808|1.26765e+30") + .runAndAssert(); + } + + @Test + public void testPrintOfHugeIntegralValues() throws Exception { + AwkTestSupport + .awkTest("print renders huge integral values in full") + .script("BEGIN { print 2^100; print int(2^100); print 2^53 }") + .expectLines("1267650600228229401496703205376", "1267650600228229401496703205376", "9007199254740992") + .runAndAssert(); + } + + @Test + public void testNonFiniteValues() throws Exception { + AwkTestSupport + .awkTest("printf prints nan and inf like gawk") + .script("BEGIN { printf \"%f|%d|%s\\n\", log(-1), log(-1), 2 * 10^308 }") + .expectLines("nan|nan|inf") + .runAndAssert(); + } + + @Test + public void testUnknownSpecifierPrintsVerbatim() throws Exception { + AwkTestSupport + .awkTest("printf unknown conversion prints verbatim without consuming arguments") + .script("BEGIN { printf \"%q%d|%kmarco|a%nb\\n\", 1, 2 }") + .expectLines("%q1|%kmarco|a%nb") + .runAndAssert(); + } + + @Test + public void testNotEnoughArgumentsIsFatal() throws Exception { + AwkTestSupport + .awkTest("printf with too few arguments is a fatal error") + .script("BEGIN { printf \"%s %s\\n\", \"a\" }") + .expectThrow(AwkRuntimeException.class) + .runAndAssert(); + } + + @Test + public void testExtraArgumentsAreIgnored() throws Exception { + AwkTestSupport + .awkTest("printf ignores extra arguments") + .script("BEGIN { printf \"%s %s\\n\", \"a\", \"b\", \"c\" }") + .expectLines("a b") + .runAndAssert(); + } + + @Test + public void testPositionalSpecifiers() throws Exception { + AwkTestSupport + .awkTest("printf gawk positional specifiers") + .script("BEGIN { printf \"%2$s %1$s\\n\", \"world\", \"hello\" }") + .expectLines("hello world") + .runAndAssert(); + } + + @Test + public void testPosixModeRejectsPositionalSpecifiers() throws Exception { + AwkTestSupport + .cliTest("printf positional specifiers are rejected in POSIX mode") + .argument("--posix") + .script("BEGIN { printf \"%2$s %1$s\\n\", \"world\", \"hello\" }") + .expectThrow(AwkRuntimeException.class) + .runAndAssert(); + } + + @Test + public void testUnterminatedStarPositionIsFatal() throws Exception { + AwkTestSupport + .awkTest("printf digits after star without dollar are fatal") + .script("BEGIN { printf \"%*2d\\n\", 5, 42 }") + .expectThrow(AwkRuntimeException.class) + .runAndAssert(); + } + + @Test + public void testMixedPositionalSpecifiersAreFatal() throws Exception { + AwkTestSupport + .awkTest("printf mixing positional and sequential specifiers is fatal") + .script("BEGIN { printf \"%2$s %s\\n\", \"a\", \"b\" }") + .expectThrow(AwkRuntimeException.class) + .runAndAssert(); + } + + @Test + public void testGroupingFlag() throws Exception { + AwkTestSupport + .awkTest("printf apostrophe flag groups thousands") + .script("BEGIN { printf \"%'d\\n\", 1234567 }") + .expectLines("1,234,567") + .runAndAssert(); + } + + @Test + public void testSprintfRoundHalfEven() throws Exception { + AwkTestSupport + .awkTest("printf %f rounds halfway cases to even like gawk") + .script("BEGIN { printf \"%.0f|%.0f|%.0f|%.2f\\n\", 2.5, 3.5, 4.5, 0.125 }") + .expectLines("2|4|4|0.12") + .runAndAssert(); + } + + @Test + public void testPrintfToFileHonorsConvfmt() throws Exception { + AwkTestSupport + .awkTest("printf to a file honors CONVFMT for %s") + .path("out.txt") + .script( + "BEGIN { CONVFMT=\"%.2g\"; f=\"{{out.txt}}\"; printf \"%s\\n\", 3.14159 > f; close(f); " + + "while ((getline x < f) > 0) print x }") + .expectLines("3.1") + .runAndAssert(); + } +} diff --git a/src/test/java/io/jawk/jrt/AwkPrintfTest.java b/src/test/java/io/jawk/jrt/AwkPrintfTest.java new file mode 100644 index 00000000..66109e1f --- /dev/null +++ b/src/test/java/io/jawk/jrt/AwkPrintfTest.java @@ -0,0 +1,935 @@ +package io.jawk.jrt; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static io.jawk.jrt.AwkPrintf.sprintf; + +import java.util.Locale; +import org.junit.Test; + +/** + * Unit tests for {@link AwkPrintf}. + *

+ * This suite incorporates the complete unit test suite of the former + * Printf4J project, + * including the tests that were disabled or commented out there. Where + * Printf4J (which emulated glibc) and AWK semantics differ, the expected + * values below were verified against gawk 5 and are annotated accordingly. + *

+ */ +public class AwkPrintfTest { + + @Test + public void testPlus() { + assertEquals("+42", sprintf("%+d", 42)); + assertEquals("-42", sprintf("%+d", -42)); + assertEquals(" +42", sprintf("%+5d", 42)); + assertEquals(" -42", sprintf("%+5d", -42)); + assertEquals(" +42", sprintf("%+15d", 42)); + assertEquals(" -42", sprintf("%+15d", -42)); + assertEquals("Hello testing", sprintf("%+s", "Hello testing")); + assertEquals("+1024", sprintf("%+d", 1024)); + assertEquals("-1024", sprintf("%+d", -1024)); + assertEquals("+1024", sprintf("%+i", 1024)); + assertEquals("-1024", sprintf("%+i", -1024)); + assertEquals("1024", sprintf("%+u", 1024)); + assertEquals("4294966272", sprintf("%+u", 4294966272L)); + assertEquals("777", sprintf("%+o", 511)); + assertEquals("37777777001", sprintf("%+o", 4294966785L)); + assertEquals("1234abcd", sprintf("%+x", 305441741)); + assertEquals("edcb5433", sprintf("%+x", 3989525555L)); + assertEquals("1234ABCD", sprintf("%+X", 305441741)); + assertEquals("EDCB5433", sprintf("%+X", 3989525555L)); + assertEquals("x", sprintf("%+c", 'x')); + // Was commented out in Printf4J expecting "0": gawk prints nothing for + // a zero value with an explicit zero precision, even with sign flags. + assertEquals("", sprintf("%+.0d", 0)); + } + + @Test + public void testBlank() { + assertEquals(" 42", sprintf("% d", 42)); + assertEquals("-42", sprintf("% d", -42)); + assertEquals(" 42", sprintf("% 5d", 42)); + assertEquals(" -42", sprintf("% 5d", -42)); + assertEquals(" 42", sprintf("% 15d", 42)); + assertEquals(" -42", sprintf("% 15d", -42)); + assertEquals(" -42", sprintf("% 15d", -42)); + assertEquals(" -42.987", sprintf("% 15.3f", -42.987)); + assertEquals(" 42.987", sprintf("% 15.3f", 42.987)); + assertEquals("Hello testing", sprintf("% s", "Hello testing")); + assertEquals(" 1024", sprintf("% d", 1024)); + assertEquals("-1024", sprintf("% d", -1024)); + assertEquals(" 1024", sprintf("% i", 1024)); + assertEquals("-1024", sprintf("% i", -1024)); + assertEquals("1024", sprintf("% u", 1024)); + assertEquals("4294966272", sprintf("% u", 4294966272L)); + assertEquals("777", sprintf("% o", 511)); + assertEquals("37777777001", sprintf("% o", 4294966785L)); + assertEquals("1234abcd", sprintf("% x", 305441741)); + assertEquals("edcb5433", sprintf("% x", 3989525555L)); + assertEquals("1234ABCD", sprintf("% X", 305441741)); + assertEquals("EDCB5433", sprintf("% X", 3989525555L)); + assertEquals("x", sprintf("% c", 'x')); + } + + @Test + public void testZero() { + assertEquals("42", sprintf("%0d", 42)); + assertEquals("42", sprintf("%0ld", 42L)); + assertEquals("-42", sprintf("%0d", -42)); + assertEquals("00042", sprintf("%05d", 42)); + assertEquals("-0042", sprintf("%05d", -42)); + assertEquals("000000000000042", sprintf("%015d", 42)); + assertEquals("-00000000000042", sprintf("%015d", -42)); + assertEquals("000000000042.12", sprintf("%015.2f", 42.1234)); + assertEquals("00000000042.988", sprintf("%015.3f", 42.9876)); + assertEquals("-00000042.98760", sprintf("%015.5f", -42.9876)); + } + + @Test + public void testMinus() { + assertEquals("42", sprintf("%-d", 42)); + assertEquals("-42", sprintf("%-d", -42)); + assertEquals("42 ", sprintf("%-5d", 42)); + assertEquals("-42 ", sprintf("%-5d", -42)); + assertEquals("42 ", sprintf("%-15d", 42)); + assertEquals("-42 ", sprintf("%-15d", -42)); + assertEquals("42", sprintf("%-0d", 42)); + assertEquals("-42", sprintf("%-0d", -42)); + assertEquals("42 ", sprintf("%-05d", 42)); + assertEquals("-42 ", sprintf("%-05d", -42)); + assertEquals("42 ", sprintf("%-015d", 42)); + assertEquals("-42 ", sprintf("%-015d", -42)); + assertEquals("42", sprintf("%0-d", 42)); + assertEquals("-42", sprintf("%0-d", -42)); + assertEquals("42 ", sprintf("%0-5d", 42)); + assertEquals("-42 ", sprintf("%0-5d", -42)); + assertEquals("42 ", sprintf("%0-15d", 42)); + assertEquals("-42 ", sprintf("%0-15d", -42)); + assertEquals("-4.200e+01 ", sprintf("%0-15.3e", -42.)); + // Printf4J expected "-42.0 ": AWK's %g removes trailing + // zeros, so gawk prints "-42 ". + assertEquals("-42 ", sprintf("%0-15.3g", -42.)); + } + + @Test + public void testHash() { + // Printf4J expected "" here, but gawk prints "0" for a zero value + // with '#' and a zero precision on %x. + assertEquals("0", sprintf("%#.0x", 0)); + // Printf4J had this assertion commented out as "the real expected + // behavior, which is wrong IMO" (it returned "0x0" instead): C and + // gawk agree on "0", which is what AwkPrintf now produces. + assertEquals("0", sprintf("%#.1x", 0)); + // "%#.0llx" is invalid in gawk: doubled length modifiers make the + // whole specifier print verbatim, without consuming an argument. + assertEquals("%#.0llx", sprintf("%#.0llx", 0)); + assertEquals("0x0000614e", sprintf("%#.8x", 0x614e)); + // Was commented out in Printf4J ("binary is not supported for now"): + // %b is not an AWK conversion, so gawk prints the specifier verbatim. + assertEquals("%#b", sprintf("%#b", 6)); + // gawk-verified: the '#' prefix depends on the original value, so a + // nonzero fraction that truncates to zero keeps the prefix. + assertEquals("0x0", sprintf("%#.0x", 0.1)); + assertEquals("0x0", sprintf("%#x", 0.5)); + // gawk-verified: '#' with %o always adds its leading zero on nonzero + // values, in addition to any precision padding. + assertEquals("00", sprintf("%#o", 0.5)); + assertEquals("00", sprintf("%#.0o", 0.2)); + assertEquals("000001", sprintf("%#.5o", 1)); + assertEquals("0010", sprintf("%#.3o", 8)); + assertEquals("010", sprintf("%#o", 8)); + } + + @Test + public void testSpecifier() { + assertEquals("Hello testing", sprintf("Hello testing")); + assertEquals("Hello testing", sprintf("%s", "Hello testing")); + assertEquals("1024", sprintf("%d", 1024)); + assertEquals("-1024", sprintf("%d", -1024)); + assertEquals("1024", sprintf("%i", 1024)); + assertEquals("-1024", sprintf("%i", -1024)); + assertEquals("1024", sprintf("%u", 1024)); + assertEquals("4294966272", sprintf("%u", 4294966272L)); + assertEquals("777", sprintf("%o", 511)); + assertEquals("37777777001", sprintf("%o", 4294966785L)); + assertEquals("1234abcd", sprintf("%x", 305441741)); + assertEquals("edcb5433", sprintf("%x", 3989525555L)); + assertEquals("1234ABCD", sprintf("%X", 305441741)); + assertEquals("EDCB5433", sprintf("%X", 3989525555L)); + assertEquals("%", sprintf("%%")); + // gawk-verified: a percent conversion ignores flags, width, and + // precision. + assertEquals("%", sprintf("%5%")); + assertEquals("%", sprintf("%-3%")); + assertEquals("%", sprintf("%0.2%")); + // ...but an explicit position still pins the format to positional + // mode, so mixing with a sequential conversion is fatal, like gawk. + assertThrows(AwkRuntimeException.class, () -> sprintf("%1$%|%s", "a")); + // gawk validates the index of a positioned conversion even when it + // consumes no argument. + assertThrows(AwkRuntimeException.class, () -> sprintf("%2$%", 1)); + assertThrows(AwkRuntimeException.class, () -> sprintf("%2$q", 1)); + assertThrows(AwkRuntimeException.class, () -> sprintf("%1$%")); + // gawk-verified: a zero-indexed star operand means the value zero + // without consuming an argument, while an out-of-range one is fatal. + assertEquals("7|42", sprintf("%*0$d|%d", 7, 42)); + assertEquals("3|42", sprintf("%.*0$f|%d", 3.14159, 42)); + assertThrows(AwkRuntimeException.class, () -> sprintf("%*5$d|%d", 7, 42)); + } + + @Test + public void testWidth() { + assertEquals("Hello testing", sprintf("%1s", "Hello testing")); + assertEquals("1024", sprintf("%1d", 1024)); + assertEquals("-1024", sprintf("%1d", -1024)); + assertEquals("1024", sprintf("%1i", 1024)); + assertEquals("-1024", sprintf("%1i", -1024)); + assertEquals("1024", sprintf("%1u", 1024)); + assertEquals("4294966272", sprintf("%1u", 4294966272L)); + assertEquals("777", sprintf("%1o", 511)); + assertEquals("37777777001", sprintf("%1o", 4294966785L)); + assertEquals("1234abcd", sprintf("%1x", 305441741)); + assertEquals("edcb5433", sprintf("%1x", 3989525555L)); + assertEquals("1234ABCD", sprintf("%1X", 305441741)); + assertEquals("EDCB5433", sprintf("%1X", 3989525555L)); + assertEquals("x", sprintf("%1c", 'x')); + } + + @Test + public void testWidth20() { + assertEquals(" Hello", sprintf("%20s", "Hello")); + assertEquals(" 1024", sprintf("%20d", 1024)); + assertEquals(" -1024", sprintf("%20d", -1024)); + assertEquals(" 1024", sprintf("%20i", 1024)); + assertEquals(" -1024", sprintf("%20i", -1024)); + assertEquals(" 1024", sprintf("%20u", 1024)); + assertEquals(" 4294966272", sprintf("%20u", 4294966272L)); + assertEquals(" 777", sprintf("%20o", 511)); + assertEquals(" 37777777001", sprintf("%20o", 4294966785L)); + assertEquals(" 1234abcd", sprintf("%20x", 305441741)); + assertEquals(" edcb5433", sprintf("%20x", 3989525555L)); + assertEquals(" 1234ABCD", sprintf("%20X", 305441741)); + assertEquals(" EDCB5433", sprintf("%20X", 3989525555L)); + assertEquals(" x", sprintf("%20c", 'x')); + } + + @Test + public void testWidthStar20() { + assertEquals(" Hello", sprintf("%*s", 20, "Hello")); + assertEquals(" 1024", sprintf("%*d", 20, 1024)); + assertEquals(" -1024", sprintf("%*d", 20, -1024)); + assertEquals(" 1024", sprintf("%*i", 20, 1024)); + assertEquals(" -1024", sprintf("%*i", 20, -1024)); + assertEquals(" 1024", sprintf("%*u", 20, 1024)); + assertEquals(" 4294966272", sprintf("%*u", 20, 4294966272L)); + assertEquals(" 777", sprintf("%*o", 20, 511)); + assertEquals(" 37777777001", sprintf("%*o", 20, 4294966785L)); + assertEquals(" 1234abcd", sprintf("%*x", 20, 305441741)); + assertEquals(" edcb5433", sprintf("%*x", 20, 3989525555L)); + assertEquals(" 1234ABCD", sprintf("%*X", 20, 305441741)); + assertEquals(" EDCB5433", sprintf("%*X", 20, 3989525555L)); + assertEquals(" x", sprintf("%*c", 20, 'x')); + } + + @Test + public void testMinus20() { + assertEquals("Hello ", sprintf("%-20s", "Hello")); + assertEquals("1024 ", sprintf("%-20d", 1024)); + assertEquals("-1024 ", sprintf("%-20d", -1024)); + assertEquals("1024 ", sprintf("%-20i", 1024)); + assertEquals("-1024 ", sprintf("%-20i", -1024)); + assertEquals("1024 ", sprintf("%-20u", 1024)); + assertEquals("1024.1234 ", sprintf("%-20.4f", 1024.1234)); + assertEquals("4294966272 ", sprintf("%-20u", 4294966272L)); + assertEquals("777 ", sprintf("%-20o", 511)); + assertEquals("37777777001 ", sprintf("%-20o", 4294966785L)); + assertEquals("1234abcd ", sprintf("%-20x", 305441741)); + assertEquals("edcb5433 ", sprintf("%-20x", 3989525555L)); + assertEquals("1234ABCD ", sprintf("%-20X", 305441741)); + assertEquals("EDCB5433 ", sprintf("%-20X", 3989525555L)); + assertEquals("x ", sprintf("%-20c", 'x')); + assertEquals("| 9| |9 | | 9|", sprintf("|%5d| |%-2d| |%5d|", 9, 9, 9)); + assertEquals("| 10| |10| | 10|", sprintf("|%5d| |%-2d| |%5d|", 10, 10, 10)); + assertEquals("| 9| |9 | | 9|", sprintf("|%5d| |%-12d| |%5d|", 9, 9, 9)); + assertEquals("| 10| |10 | | 10|", sprintf("|%5d| |%-12d| |%5d|", 10, 10, 10)); + } + + @Test + public void testZeroMinus20() { + assertEquals("Hello ", sprintf("%0-20s", "Hello")); + assertEquals("1024 ", sprintf("%0-20d", 1024)); + assertEquals("-1024 ", sprintf("%0-20d", -1024)); + assertEquals("1024 ", sprintf("%0-20i", 1024)); + assertEquals("-1024 ", sprintf("%0-20i", -1024)); + assertEquals("1024 ", sprintf("%0-20u", 1024)); + assertEquals("4294966272 ", sprintf("%0-20u", 4294966272L)); + assertEquals("777 ", sprintf("%0-20o", 511)); + assertEquals("37777777001 ", sprintf("%0-20o", 4294966785L)); + assertEquals("1234abcd ", sprintf("%0-20x", 305441741)); + assertEquals("edcb5433 ", sprintf("%0-20x", 3989525555L)); + assertEquals("1234ABCD ", sprintf("%0-20X", 305441741)); + assertEquals("EDCB5433 ", sprintf("%0-20X", 3989525555L)); + assertEquals("x ", sprintf("%0-20c", 'x')); + } + + @Test + public void testPadding20() { + assertEquals("00000000000000001024", sprintf("%020d", 1024)); + assertEquals("-0000000000000001024", sprintf("%020d", -1024)); + assertEquals("00000000000000001024", sprintf("%020i", 1024)); + assertEquals("-0000000000000001024", sprintf("%020i", -1024)); + assertEquals("00000000000000001024", sprintf("%020u", 1024)); + assertEquals("00000000004294966272", sprintf("%020u", 4294966272L)); + assertEquals("00000000000000000777", sprintf("%020o", 511)); + assertEquals("00000000037777777001", sprintf("%020o", 4294966785L)); + assertEquals("0000000000001234abcd", sprintf("%020x", 305441741)); + assertEquals("000000000000edcb5433", sprintf("%020x", 3989525555L)); + assertEquals("0000000000001234ABCD", sprintf("%020X", 305441741)); + assertEquals("000000000000EDCB5433", sprintf("%020X", 3989525555L)); + } + + @Test + public void testPaddingPrecision20() { + assertEquals("00000000000000001024", sprintf("%.20d", 1024)); + assertEquals("-00000000000000001024", sprintf("%.20d", -1024)); + assertEquals("00000000000000001024", sprintf("%.20i", 1024)); + assertEquals("-00000000000000001024", sprintf("%.20i", -1024)); + assertEquals("00000000000000001024", sprintf("%.20u", 1024)); + assertEquals("00000000004294966272", sprintf("%.20u", 4294966272L)); + assertEquals("00000000000000000777", sprintf("%.20o", 511)); + assertEquals("00000000037777777001", sprintf("%.20o", 4294966785L)); + assertEquals("0000000000001234abcd", sprintf("%.20x", 305441741)); + assertEquals("000000000000edcb5433", sprintf("%.20x", 3989525555L)); + assertEquals("0000000000001234ABCD", sprintf("%.20X", 305441741)); + assertEquals("000000000000EDCB5433", sprintf("%.20X", 3989525555L)); + } + + @Test + public void testPaddingHashZero20() { + assertEquals("00000000000000001024", sprintf("%#020d", 1024)); + assertEquals("-0000000000000001024", sprintf("%#020d", -1024)); + assertEquals("00000000000000001024", sprintf("%#020i", 1024)); + assertEquals("-0000000000000001024", sprintf("%#020i", -1024)); + assertEquals("00000000000000001024", sprintf("%#020u", 1024)); + assertEquals("00000000004294966272", sprintf("%#020u", 4294966272L)); + assertEquals("00000000000000000777", sprintf("%#020o", 511)); + assertEquals("00000000037777777001", sprintf("%#020o", 4294966785L)); + assertEquals("0x00000000001234abcd", sprintf("%#020x", 305441741)); + assertEquals("0x0000000000edcb5433", sprintf("%#020x", 3989525555L)); + assertEquals("0X00000000001234ABCD", sprintf("%#020X", 305441741)); + assertEquals("0X0000000000EDCB5433", sprintf("%#020X", 3989525555L)); + } + + @Test + public void testPaddingHash20() { + assertEquals(" 1024", sprintf("%#20d", 1024)); + assertEquals(" -1024", sprintf("%#20d", -1024)); + assertEquals(" 1024", sprintf("%#20i", 1024)); + assertEquals(" -1024", sprintf("%#20i", -1024)); + assertEquals(" 1024", sprintf("%#20u", 1024)); + assertEquals(" 4294966272", sprintf("%#20u", 4294966272L)); + // The following assertions were commented out in Printf4J; they match + // C and gawk, and now pass. + assertEquals(" 0777", sprintf("%#20o", 511)); + assertEquals(" 037777777001", sprintf("%#20o", 4294966785L)); + assertEquals(" 0x1234abcd", sprintf("%#20x", 305441741)); + assertEquals(" 0xedcb5433", sprintf("%#20x", 3989525555L)); + assertEquals(" 0X1234ABCD", sprintf("%#20X", 305441741)); + assertEquals(" 0XEDCB5433", sprintf("%#20X", 3989525555L)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testPadding20Dot5() { + assertEquals(" 01024", sprintf("%20.5d", 1024)); + assertEquals(" -01024", sprintf("%20.5d", -1024)); + assertEquals(" 01024", sprintf("%20.5i", 1024)); + assertEquals(" -01024", sprintf("%20.5i", -1024)); + assertEquals(" 01024", sprintf("%20.5u", 1024)); + assertEquals(" 4294966272", sprintf("%20.5u", 4294966272L)); + assertEquals(" 00777", sprintf("%20.5o", 511)); + assertEquals(" 37777777001", sprintf("%20.5o", 4294966785L)); + assertEquals(" 1234abcd", sprintf("%20.5x", 305441741)); + assertEquals(" 00edcb5433", sprintf("%20.10x", 3989525555L)); + assertEquals(" 1234ABCD", sprintf("%20.5X", 305441741)); + assertEquals(" 00EDCB5433", sprintf("%20.10X", 3989525555L)); + } + + // Was @Disabled in Printf4J; matches C and gawk. + @Test + public void testPaddingNegativeNumbers() { + // space padding + assertEquals("-5", sprintf("% 1d", -5)); + assertEquals("-5", sprintf("% 2d", -5)); + assertEquals(" -5", sprintf("% 3d", -5)); + assertEquals(" -5", sprintf("% 4d", -5)); + // zero padding + assertEquals("-5", sprintf("%01d", -5)); + assertEquals("-5", sprintf("%02d", -5)); + assertEquals("-05", sprintf("%03d", -5)); + assertEquals("-005", sprintf("%04d", -5)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testPaddingNegativeFloat() { + // space padding + assertEquals("-5.0", sprintf("% 3.1f", -5.)); + assertEquals("-5.0", sprintf("% 4.1f", -5.)); + assertEquals(" -5.0", sprintf("% 5.1f", -5.)); + assertEquals(" -5", sprintf("% 6.1g", -5.)); + assertEquals("-5.0e+00", sprintf("% 6.1e", -5.)); + assertEquals(" -5.0e+00", sprintf("% 10.1e", -5.)); + // zero padding + assertEquals("-5.0", sprintf("%03.1f", -5.)); + assertEquals("-5.0", sprintf("%04.1f", -5.)); + assertEquals("-05.0", sprintf("%05.1f", -5.)); + // zero padding no decimal point + assertEquals("-5", sprintf("%01.0f", -5.)); + assertEquals("-5", sprintf("%02.0f", -5.)); + assertEquals("-05", sprintf("%03.0f", -5.)); + assertEquals("-005.0e+00", sprintf("%010.1e", -5.)); + assertEquals("-05E+00", sprintf("%07.0E", -5.)); + assertEquals("-05", sprintf("%03.0g", -5.)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testLength() { + assertEquals("", sprintf("%.0s", "Hello testing")); + assertEquals(" ", sprintf("%20.0s", "Hello testing")); + assertEquals("", sprintf("%.s", "Hello testing")); + assertEquals(" ", sprintf("%20.s", "Hello testing")); + assertEquals(" 1024", sprintf("%20.0d", 1024)); + assertEquals(" -1024", sprintf("%20.0d", -1024)); + assertEquals(" ", sprintf("%20.d", 0)); + assertEquals(" 1024", sprintf("%20.0i", 1024)); + assertEquals(" -1024", sprintf("%20.i", -1024)); + assertEquals(" ", sprintf("%20.i", 0)); + assertEquals(" 1024", sprintf("%20.u", 1024)); + assertEquals(" 4294966272", sprintf("%20.0u", 4294966272L)); + assertEquals(" ", sprintf("%20.u", 0L)); + assertEquals(" 777", sprintf("%20.o", 511)); + assertEquals(" 37777777001", sprintf("%20.0o", 4294966785L)); + assertEquals(" ", sprintf("%20.o", 0L)); + assertEquals(" 1234abcd", sprintf("%20.x", 305441741)); + assertEquals(" 1234abcd", sprintf("%50.x", 305441741)); + assertEquals( + " 1234abcd 12345", + sprintf("%50.x%10.u", 305441741, 12345)); + assertEquals(" edcb5433", sprintf("%20.0x", 3989525555L)); + assertEquals(" ", sprintf("%20.x", 0L)); + assertEquals(" 1234ABCD", sprintf("%20.X", 305441741)); + assertEquals(" EDCB5433", sprintf("%20.0X", 3989525555L)); + assertEquals(" ", sprintf("%20.X", 0L)); + assertEquals(" ", sprintf("%02.0u", 0L)); + assertEquals(" ", sprintf("%02.0d", 0)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testFloat() { + // test special-case floats + assertEquals(" nan", sprintf("%8f", Float.NaN)); + assertEquals(" inf", sprintf("%8f", Float.POSITIVE_INFINITY)); + assertEquals("-inf ", sprintf("%-8f", Float.NEGATIVE_INFINITY)); + assertEquals(" +inf", sprintf("%+8e", Float.POSITIVE_INFINITY)); + assertEquals("3.1415", sprintf("%.4f", 3.1415354)); + assertEquals("30343.142", sprintf("%.3f", 30343.1415354)); + assertEquals("34", sprintf("%.0f", 34.1415354)); + assertEquals("1", sprintf("%.0f", 1.3)); + assertEquals("2", sprintf("%.0f", 1.55)); + assertEquals("1.6", sprintf("%.1f", 1.64)); + assertEquals("42.90", sprintf("%.2f", 42.8952)); + assertEquals("42.895200000", sprintf("%.9f", 42.8952)); + assertEquals("42.8952230000", sprintf("%.10f", 42.895223)); + // Printf4J expected "42.895223123000" and "42.895223877000" here + // because its reference implementation truncated to 9 significant + // fraction digits; gawk prints the correctly rounded values. + assertEquals("42.895223123457", sprintf("%.12f", 42.89522312345678)); + assertEquals("42.895223876543", sprintf("%.12f", 42.89522387654321)); + assertEquals(" 42.90", sprintf("%6.2f", 42.8952)); + assertEquals("+42.90", sprintf("%+6.2f", 42.8952)); + assertEquals("+42.9", sprintf("%+5.1f", 42.9252)); + assertEquals("42.500000", sprintf("%f", 42.5)); + assertEquals("42.5", sprintf("%.1f", 42.5)); + assertEquals("42167.000000", sprintf("%f", 42167.0)); + assertEquals("-12345.987654321", sprintf("%.9f", -12345.987654321)); + assertEquals("4.0", sprintf("%.1f", 3.999)); + assertEquals("4", sprintf("%.0f", 3.5)); + assertEquals("4", sprintf("%.0f", 4.5)); + assertEquals("3", sprintf("%.0f", 3.49)); + assertEquals("3.5", sprintf("%.1f", 3.49)); + assertEquals("a0.5 ", sprintf("a%-5.1f", 0.5)); + assertEquals("a0.5 end", sprintf("a%-5.1fend", 0.5)); + assertEquals("12345.7", sprintf("%G", 12345.678)); + assertEquals("12345.68", sprintf("%.7G", 12345.678)); + assertEquals("1.2346E+08", sprintf("%.5G", 123456789.)); + // Printf4J expected "12345.0": AWK's %G removes trailing zeros. + assertEquals("12345", sprintf("%.6G", 12345.)); + assertEquals(" +1.235e+08", sprintf("%+12.4g", 123456789.)); + assertEquals("0.0012", sprintf("%.2G", 0.001234)); + assertEquals(" +0.001234", sprintf("%+10.4G", 0.001234)); + assertEquals("+001.234e-05", sprintf("%+012.4g", 0.00001234)); + assertEquals("-1.23e-308", sprintf("%.3g", -1.2345e-308)); + assertEquals("+1.230E+308", sprintf("%+.3E", 1.23e+308)); + // Printf4J expected "1.0e+20" (its reference implementation switched + // to exponential notation out of range); gawk prints the full value. + assertEquals("100000000000000000000.0", sprintf("%.1f", 1E20)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5, + // which only accepts a single 'h', 'l', or 'L' length modifier and + // prints any other modifier combination verbatim. + @Test + public void testTypes() { + assertEquals("0", sprintf("%i", 0)); + assertEquals("1234", sprintf("%i", 1234)); + assertEquals("32767", sprintf("%i", 32767)); + assertEquals("-32767", sprintf("%i", -32767)); + assertEquals("30", sprintf("%li", 30L)); + assertEquals("-2147483647", sprintf("%li", -2147483647L)); + assertEquals("2147483647", sprintf("%li", 2147483647L)); + // Doubled modifiers ("ll", "hh") and the "q" modifier are not valid + // in gawk: the specifier prints verbatim and consumes no argument. + assertEquals("%lli", sprintf("%lli", 30L)); + assertEquals("%lli", sprintf("%lli", -9223372036854775807L)); + assertEquals("%lli", sprintf("%lli", 9223372036854775807L)); + assertEquals("100000", sprintf("%lu", 100000L)); + assertEquals("4294967295", sprintf("%lu", 0xFFFFFFFFL)); + assertEquals("%llu", sprintf("%llu", 281474976710656L)); + assertEquals("%llu", sprintf("%llu", Long.parseUnsignedLong("18446744073709551615"))); + // Single j, z, and t modifiers are accepted and ignored, like h, l, + // and L (gawk 5.2+). + assertEquals("2147483647", sprintf("%zu", 2147483647L)); + assertEquals("2147483647", sprintf("%zd", 2147483647L)); + assertEquals("-2147483647", sprintf("%zi", -2147483647L)); + assertEquals("5", sprintf("%jd", 5)); + assertEquals("6", sprintf("%td", 6)); + // Distinct modifiers may stack; only repeats are invalid. + assertEquals("42", sprintf("%lhd", 42)); + // %b is not an AWK conversion: printed verbatim, like gawk. + assertEquals("%b", sprintf("%b", 60000)); + assertEquals("%lb", sprintf("%lb", 12345678L)); + assertEquals("165140", sprintf("%o", 60000)); + assertEquals("57060516", sprintf("%lo", 12345678L)); + assertEquals("12345678", sprintf("%lx", 0x12345678L)); + assertEquals("%llx", sprintf("%llx", 0x1234567891234567L)); + assertEquals("abcdefab", sprintf("%lx", 0xabcdefabL)); + assertEquals("ABCDEFAB", sprintf("%lX", 0xabcdefabL)); + assertEquals("v", sprintf("%c", 'v')); + assertEquals("wv", sprintf("%cv", 'w')); + assertEquals("A Test", sprintf("%s", "A Test")); + // gawk ignores the single 'h' modifier without truncating the value, + // and prints the invalid "hh" specifiers verbatim. + assertEquals("%hhu", sprintf("%hhu", 0xFFFFL)); + assertEquals("13398", sprintf("%hu", 13398)); + assertEquals("1193046", sprintf("%hu", 0x123456L)); + assertEquals("Test%hhi 10000", sprintf("%s%hhi %hu", "Test", 10000, 0xFFFFFFFFL)); + } + + // Was @Disabled in Printf4J, which expected "kmarco": gawk prints the + // unknown "%k" specifier verbatim. + @Test + public void testUnknown() { + assertEquals("%kmarco", sprintf("%kmarco", 42, 37)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testStringLength() { + assertEquals("This", sprintf("%.4s", "This is a test")); + assertEquals("test", sprintf("%.4s", "test")); + assertEquals("123", sprintf("%.7s", "123")); + assertEquals("", sprintf("%.7s", "")); + assertEquals("1234ab", sprintf("%.4s%.2s", "123456", "abcdef")); + // Printf4J expected ".2s": gawk prints the whole invalid specifier + // verbatim. + assertEquals("%.4.2s", sprintf("%.4.2s", "123456")); + assertEquals("123", sprintf("%.*s", 3, "123456")); + // The precision counts characters, so it never splits a surrogate + // pair, like gawk in a multibyte locale. + assertEquals("😀", sprintf("%.1s", "😀x")); + assertEquals("😀x", sprintf("%.2s", "😀x")); + // The field width also counts characters: a supplementary character + // fills one column (gawk pads %s the same way; its %c padding counts + // bytes, a C-locale artifact that Jawk does not reproduce). + assertEquals(" 😀", sprintf("%3s", "😀")); + assertEquals("😀 ", sprintf("%-3s", "😀")); + assertEquals(" 😀", sprintf("%3c", 0x1F600)); + } + + // Was @Disabled in Printf4J; expected values verified against gawk 5. + @Test + public void testMisc() { + assertEquals("53000atest-20 bit", sprintf("%u%u%ctest%d %s", 5, 3000, 'a', -20, "bit")); + assertEquals("0.33", sprintf("%.*f", 2, 0.33333333)); + assertEquals("1", sprintf("%.*d", -1, 1)); + assertEquals("foo", sprintf("%.3s", "foobar")); + // Printf4J expected " " (glibc behavior): gawk prints nothing at all + // for a zero value with zero precision, even with the space flag. + assertEquals("", sprintf("% .0d", 0)); + assertEquals(" 00004", sprintf("%10.5d", 4)); + assertEquals("hi x", sprintf("%*sx", -3, "hi")); + assertEquals("0.33", sprintf("%.*g", 2, 0.33333333)); + assertEquals("3.33e-01", sprintf("%.*e", 2, 0.33333333)); + } + + @Test + public void testChar() { + assertEquals("A", sprintf("%c", 65)); + assertEquals("A", sprintf("%c", 65L)); + assertEquals("A", sprintf("%c", 65.0)); + assertEquals("A", sprintf("%c", 65.1)); + assertEquals("A", sprintf("%c", Integer.valueOf(65))); + assertEquals("A", sprintf("%c", Long.valueOf(65))); + assertEquals("A", sprintf("%c", Float.valueOf(65))); + assertEquals("A", sprintf("%c", Double.valueOf(65))); + assertEquals("6", sprintf("%c", "65")); + Object nothing = null; + assertEquals("\0", sprintf("%c", nothing)); + } + + // Ported from Printf4J's testToChar; AwkPrintf converts values for %c + // internally, so the equivalent assertions go through sprintf(). + @Test + public void testToChar() { + assertEquals("A", sprintf("%c", 65)); + assertEquals("A", sprintf("%c", 65L)); + assertEquals("A", sprintf("%c", 65.0)); + assertEquals("A", sprintf("%c", 65.1)); + assertEquals("A", sprintf("%c", 65.9)); + assertEquals("A", sprintf("%c", Integer.valueOf(65))); + assertEquals("A", sprintf("%c", Long.valueOf(65))); + assertEquals("A", sprintf("%c", Float.valueOf(65))); + assertEquals("A", sprintf("%c", Double.valueOf(65))); + assertEquals("6", sprintf("%c", "65")); + assertEquals("\0", sprintf("%c", "")); + Object nothing = null; + assertEquals("\0", sprintf("%c", nothing)); + } + + // Ported from Printf4J's testToLong: the same conversion now lives in + // JRT.toLong (they shared the same original implementation). + @Test + public void testToLong() { + assertEquals(65L, JRT.toLong('A')); + assertEquals(65L, JRT.toLong(65)); + assertEquals(65L, JRT.toLong(65L)); + assertEquals(65L, JRT.toLong(65.0)); + assertEquals(65L, JRT.toLong(65.1)); + assertEquals(65L, JRT.toLong(65.9)); + assertEquals(65L, JRT.toLong(Integer.valueOf(65))); + assertEquals(65L, JRT.toLong(Long.valueOf(65))); + assertEquals(65L, JRT.toLong(Float.valueOf(65))); + assertEquals(65L, JRT.toLong(Double.valueOf(65))); + assertEquals(65L, JRT.toLong("65")); + assertEquals(65L, JRT.toLong("65A")); + assertEquals(65L, JRT.toLong("65A6666666666666666666666666600000000033333333333999999999999")); + assertEquals(0L, JRT.toLong("")); + Object nothing = null; + assertEquals(0L, JRT.toLong(nothing)); + } + + // Ported from Printf4J's testToDouble: the same conversion now lives in + // JRT.toDouble (they shared the same original implementation). + @Test + public void testToDouble() { + assertEquals(65.0, JRT.toDouble('A'), 0.0); + assertEquals(65.0, JRT.toDouble(65), 0.0); + assertEquals(65.0, JRT.toDouble(65L), 0.0); + assertEquals(65.0, JRT.toDouble(65.0), 0.0); + assertEquals(65.1, JRT.toDouble(65.1), 0.0); + assertEquals(65.9, JRT.toDouble(65.9), 0.0); + assertEquals(65.0, JRT.toDouble(Integer.valueOf(65)), 0.0); + assertEquals(65.0, JRT.toDouble(Long.valueOf(65)), 0.0); + assertEquals(65.0, JRT.toDouble(Float.valueOf(65)), 0.0); + assertEquals(65.0, JRT.toDouble(Double.valueOf(65)), 0.0); + assertEquals(65.0, JRT.toDouble("65"), 0.0); + assertEquals(65.0, JRT.toDouble("65A"), 0.0); + assertEquals(65.0, JRT.toDouble("65A6666666666666666666666666600000000033333333333999999999999"), 0.0); + assertEquals(65.0, JRT.toDouble("6.5E+1"), 0.0); + assertEquals(0.0, JRT.toDouble(""), 0.0); + Object nothing = null; + assertEquals(0.0, JRT.toDouble(nothing), 0.0); + } + + // ------------------------------------------------------------------ + // AWK-specific semantics beyond the original Printf4J suite. + // ------------------------------------------------------------------ + + @Test + public void testStringConversionUsesAwkNumberToStringRules() { + // The symptom from issue #528: an integral double prints without a + // fractional part. + assertEquals("1", sprintf("%s", 1.0)); + assertEquals("x[1]", sprintf("x[%s]", 1.0)); + // Non-integral values use CONVFMT. + assertEquals("3.14159", sprintf("%s", 3.14159265)); + assertEquals("3.1", sprintf(Locale.US, "%.2g", "%s", 3.14159265)); + // CONVFMT that is not a %g-style format is honored verbatim. + assertEquals("3.14", sprintf(Locale.US, "%.2f", "%s", 3.14159265)); + // An explicitly empty CONVFMT converts non-integral numbers to the + // empty string, like gawk; integral values still print as integers. + assertEquals("", sprintf(Locale.US, "", "%s", 1.5)); + assertEquals("1", sprintf(Locale.US, "", "%s", 1.0)); + // Integral values beyond the 64-bit range print in full. + assertEquals("100000000000000000000", sprintf("%s", 1e20)); + // Exact long values are preserved. + assertEquals("9223372036854775807", sprintf("%s", Long.MAX_VALUE)); + } + + @Test + public void testCharConversion() { + // A numeric value selects the corresponding code point. + assertEquals("é", sprintf("%c", 233)); + // A code point beyond the BMP produces the full character. + assertEquals(new String(Character.toChars(0x1F600)), sprintf("%c", 0x1F600)); + // A string value uses its first character. + assertEquals("X", sprintf("%c", "XYZ")); + // Width applies to %c like any other conversion. + assertEquals(" A", sprintf("%5c", 65)); + assertEquals("A ", sprintf("%-5c", 65)); + } + + @Test + public void testDynamicWidthAndPrecision() { + assertEquals(" 3.14", sprintf("%*.*f", 8, 2, 3.14159)); + assertEquals(" 3.14159", sprintf("%9s", 3.14159)); + // A negative dynamic width means left justification. + assertEquals("42 ", sprintf("%*d", -6, 42)); + // Width and precision arguments are converted like AWK numbers. + assertEquals(" 3.14", sprintf("%*.*f", "6", "2", 3.14159)); + } + + @Test + public void testPositionalSpecifiers() { + assertEquals("b a", sprintf("%2$s %1$s", "a", "b")); + assertEquals("a b a", sprintf("%1$s %2$s %1$s", "a", "b")); + // Mixing positional and sequential specifiers is fatal, like gawk. + assertThrows(AwkRuntimeException.class, () -> sprintf("%2$s %s", "a", "b")); + // A zero positional index is fatal, like gawk. + assertThrows(AwkRuntimeException.class, () -> sprintf("%0$s", "a")); + // gawk-verified: an explicitly positioned star operand may accompany + // sequential conversions. + assertEquals(" a|5", sprintf("%*2$s|%s", "a", 5)); + assertEquals("a 5", sprintf("%1$s %2$*3$d", "a", 5, 6)); + // gawk-verified: a sequential star operand with a positional + // conversion is a mixed-mode fatal error... + assertThrows(AwkRuntimeException.class, () -> sprintf("%2$*d", 5, 12)); + // ...and an explicitly positioned unknown specifier pins the format + // to positional mode even though it prints verbatim. + assertThrows(AwkRuntimeException.class, () -> sprintf("%2$q|%d", 5, 12)); + } + + @Test + public void testOutOfRangeFallbackKeepsSignFlagsAndPrecision() { + // gawk-verified: the %g fallback for out-of-range %u/%o/%x/%X keeps + // the sign, the precision, and the zero and '#' flags. + assertEquals("-1.26765e+30", sprintf("%u", -Math.pow(2, 100))); + assertEquals("1.2676506e+30", sprintf("%.10x", Math.pow(2, 100))); + assertEquals("1.27e+30", sprintf("%#.3x", Math.pow(2, 100))); + assertEquals("0000000001.26765e+30", sprintf("%020u", Math.pow(2, 100))); + } + + @Test + public void testAlternateFormKeepsDecimalPoint() { + // gawk-verified: '#' forces a decimal point even when no fractional + // digits remain. + assertEquals("1.", sprintf("%#.1g", 1)); + assertEquals("1.e+04", sprintf("%#.1g", 12345)); + assertEquals("1.2e+04", sprintf("%#.2g", 12345)); + assertEquals("1.e+04", sprintf("%#.0e", 12345)); + assertEquals("1.00000", sprintf("%#g", 1)); + } + + @Test + public void testZeroPrecisionZeroValue() { + // gawk-verified: unsigned conversions print "0" when a nonzero value + // truncates to zero, or when the '#' flag is given; signed %d prints + // nothing in both zero cases. + assertEquals("0", sprintf("%.0x", 0.1)); + assertEquals("0", sprintf("%.0u", 0.1)); + assertEquals("0", sprintf("%.0o", 0.1)); + assertEquals("", sprintf("%.0d", 0.1)); + assertEquals("0", sprintf("%#.0u", 0)); + assertEquals("", sprintf("%.0u", 0)); + assertEquals("", sprintf("%.0x", 0)); + } + + @Test + public void testIntegerTruncationAndConversion() { + // %d truncates toward zero. + assertEquals("42", sprintf("%d", 42.7)); + assertEquals("-42", sprintf("%d", -42.7)); + // Strings convert with AWK's number rules (leading/trailing spaces, + // exponent notation, numeric prefixes). + assertEquals("1000", sprintf("%d", "1e3")); + assertEquals("42", sprintf("%d", " 42 ")); + assertEquals("3", sprintf("%d", "+3.9")); + assertEquals("0", sprintf("%d", "abc")); + assertEquals("0", sprintf("%x", "abc")); + } + + @Test + public void testOutOfRangeIntegerConversions() { + // Negative values wrap to unsigned 64-bit for %u, %o, %x. + assertEquals("18446744073709551615", sprintf("%u", -1)); + assertEquals("ffffffffffffffff", sprintf("%x", -1)); + assertEquals("1777777777777777777777", sprintf("%o", -1)); + // 2^63 is out of the signed range but fits unsigned. + assertEquals("9223372036854775808", sprintf("%d", 9.223372036854775808e18)); + // %d beyond 64 bits prints the full decimal expansion, like gawk. + assertEquals("1267650600228229401496703205376", sprintf("%d", Math.pow(2, 100))); + assertEquals("-1267650600228229401496703205376", sprintf("%d", -Math.pow(2, 100))); + // %u, %o, and %x beyond 64 bits fall back to %g notation, like gawk. + assertEquals("1.26765e+30", sprintf("%x", Math.pow(2, 100))); + assertEquals("1.26765e+30", sprintf("%u", Math.pow(2, 100))); + assertEquals("1.26765e+30", sprintf("%o", Math.pow(2, 100))); + } + + @Test + public void testHexFloat() { + // %a uses Java's hexadecimal float notation (gawk documents %a as + // C-library dependent); the 0x prefix stays ahead of zero padding. + assertEquals("0x1.34ap10", sprintf("%a", 1234.5)); + assertEquals("0x00000000001.34ap10", sprintf("%020a", 1234.5)); + assertEquals("-0x1.34ap10", sprintf("%a", -1234.5)); + } + + @Test + public void testNonFiniteValues() { + assertEquals("nan", sprintf("%d", Double.NaN)); + assertEquals("inf", sprintf("%d", Double.POSITIVE_INFINITY)); + assertEquals("-inf", sprintf("%f", Double.NEGATIVE_INFINITY)); + assertEquals("INF", sprintf("%E", Double.POSITIVE_INFINITY)); + assertEquals("NAN", sprintf("%G", Double.NaN)); + assertEquals("nan", sprintf("%s", Double.NaN)); + assertEquals("inf", sprintf("%s", Double.POSITIVE_INFINITY)); + assertEquals("-inf", sprintf("%s", Double.NEGATIVE_INFINITY)); + } + + @Test + public void testUnknownSpecifiersDoNotConsumeArguments() { + // The unknown %q prints verbatim and its argument feeds %d instead. + assertEquals("%q1", sprintf("%q%d", 1, 2)); + // %n is not an AWK conversion (Printf4J used to print a newline). + assertEquals("a%nb", sprintf("a%nb")); + // A dangling % prints verbatim. + assertEquals("abc%", sprintf("abc%")); + } + + @Test + public void testNotEnoughArgumentsIsFatal() { + assertThrows(AwkRuntimeException.class, () -> sprintf("%d %s", 1)); + assertThrows(AwkRuntimeException.class, () -> sprintf("%5s")); + assertThrows(AwkRuntimeException.class, () -> sprintf("%*d", 5)); + } + + @Test + public void testExtraArgumentsAreIgnored() { + assertEquals("a b", sprintf("%s %s", "a", "b", "c")); + } + + @Test + public void testGroupingFlag() { + assertEquals("1,234,567", sprintf("%'d", 1234567)); + assertEquals("1,234,567", sprintf("%'u", 1234567)); + assertEquals("1,234,567.89", sprintf("%'.2f", 1234567.891)); + assertEquals("1.234.567", sprintf(Locale.GERMANY, AwkPrintf.DEFAULT_CONVFMT, "%'d", 1234567)); + // gawk-verified: %g groups in fixed notation only, and octal and + // hexadecimal output is never grouped. + assertEquals("12,345", sprintf("%'g", 12345)); + assertEquals("1,234,567.25", sprintf("%'.10g", 1234567.25)); + assertEquals("1.234567e+06", sprintf("%'e", 1234567)); + assertEquals("2540be400", sprintf("%'x", 10000000000L)); + assertEquals("1747", sprintf("%'o", 999)); + } + + @Test + public void testLocaleDecimalSeparator() { + assertEquals("3,14", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%.2f", 3.14159)); + assertEquals("3,14159", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%g", 3.14159)); + // The '#' decimal point follows the locale as well. + assertEquals("1,", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%#.0f", 1)); + assertEquals("1,e+04", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%#.0e", 12345)); + assertEquals("1,e+04", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%#.1g", 12345)); + assertEquals("0,00000", sprintf(Locale.FRANCE, AwkPrintf.DEFAULT_CONVFMT, "%#g", 0)); + } + + @Test + public void testAwkSinkSprintf() { + // The sink-level sprintf converts %s operands with the supplied + // CONVFMT, and overriding it customizes formatting. + AwkSink plainSink = new AwkSink() { + + @Override + public void print(String ofs, String ors, String ofmt, Object... values) { + // not needed for this test + } + + @Override + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { + // not needed for this test + } + }; + assertEquals("3.1", plainSink.sprintf("%.2g", "%s", 3.14159265)); + + AwkSink customSink = new AwkSink() { + + @Override + public void print(String ofs, String ors, String ofmt, Object... values) { + // not needed for this test + } + + @Override + public void printf(String ofs, String ors, String ofmt, String convfmt, String format, Object... values) { + // not needed for this test + } + + @Override + public String sprintf(String convfmt, String format, Object... values) { + return "[" + super.sprintf(convfmt, format, values) + "]"; + } + }; + assertEquals("[3.1]", customSink.sprintf("%.2g", "%s", 3.14159265)); + } + + @Test + public void testToAwkString() { + assertEquals("", AwkPrintf.toAwkString(null, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("text", AwkPrintf.toAwkString("text", AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("1", AwkPrintf.toAwkString(1.0, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("0.1", AwkPrintf.toAwkString(0.1, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("3.14159", AwkPrintf.toAwkString(3.14159265, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("100000000000000000000", AwkPrintf.toAwkString(1e20, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("9223372036854775807", AwkPrintf.toAwkString(Long.MAX_VALUE, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("nan", AwkPrintf.toAwkString(Double.NaN, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("inf", AwkPrintf.toAwkString(Double.POSITIVE_INFINITY, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + assertEquals("-inf", AwkPrintf.toAwkString(Double.NEGATIVE_INFINITY, AwkPrintf.DEFAULT_CONVFMT, Locale.US)); + } +}