for (final Ast.PrintStmt.Item item : s.items()) {
final Object value = eval(item.expr());
vga.print(value instanceof String text ? text
- : basicNumber(num(value, s.line()), exprIsDouble(item.expr())));
+ : PrintFormat.number(num(value, s.line()), exprIsDouble(item.expr())));
if (item.separatorAfter() == ',') {
printZoneAdvance();
}
}
}
- /**
- * PRINT USING: walks the format left to right; literal characters print
- * as-is ('_' escapes the next char), each format field consumes one
- * item in order. String fields: '!' first char, '&' whole string,
- * '\\...\\' fixed width. Numeric fields: runs of '#', '.', '$', '*',
- * '+', '-', ',' — right-justified, decimals rounded half-even, '$$'
- * floats the dollar sign, '*' fills with asterisks, overflow prints
- * '%' plus the number. When items outlast the format, QB restarts the
- * format after a newline; the current examples never do.
- */
- private String usingFormatted(final Ast.PrintStmt s) {
- final String format = str(eval(s.usingFormat()), s.line());
- final StringBuilder out = new StringBuilder();
- int itemIndex = 0;
- int pos = 0;
- // Literals print as walked; a FIELD consumes the next item. The
- // walk stops at the first field with no item left — trailing
- // literals after the last consumed item still print.
- while (pos < format.length()) {
- final char c = format.charAt(pos);
- if (c == '_' && pos + 1 < format.length()) {
- out.append(format.charAt(pos + 1));
- pos += 2;
- continue;
- }
- final boolean isStringField = c == '!' || c == '&' || c == '\\';
- final boolean isNumericField = "#.*$+-,".indexOf(c) >= 0;
- if ((isStringField || isNumericField) && itemIndex >= s.items().size()) {
- break;
- }
- if (c == '!' || c == '&') {
- final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
- out.append(c == '!' ? (text.isEmpty() ? " " : text.charAt(0)) : text);
- itemIndex++;
- pos++;
- continue;
- }
- if (c == '\\') {
- final int close = format.indexOf('\\', pos + 1);
- final int width = close < 0 ? 2 : close - pos + 1;
- final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
- out.append(text.length() >= width ? text.substring(0, width)
- : text + " ".repeat(width - text.length()));
- itemIndex++;
- pos = close < 0 ? format.length() : close + 1;
- continue;
- }
- if ("#.*$+-,".indexOf(c) >= 0) {
- final int start = pos;
- while (pos < format.length() && "#.,$*^".indexOf(format.charAt(pos)) >= 0) {
- pos++;
- }
- if (pos < format.length() && "+-".indexOf(format.charAt(pos)) >= 0) {
- pos++;
- }
- final Object value = eval(s.items().get(itemIndex).expr());
- out.append(formatNumericField(format.substring(start, pos),
- num(value, s.line())));
- itemIndex++;
- continue;
- }
- out.append(c);
- pos++;
- }
- return out.toString();
- }
-
- /** Formats one number into one PRINT USING numeric field. */
- private static String formatNumericField(final String spec, final double value) {
- final boolean starFill = spec.indexOf('*') >= 0;
- final boolean dollar = spec.indexOf('$') >= 0;
- final boolean dollarFloat = spec.startsWith("$$") || spec.contains("**$");
- final boolean trailingMinus = spec.endsWith("-");
- final int dot = spec.indexOf('.');
- int slots = 0;
- int decimals = 0;
- for (int i = 0; i < spec.length(); i++) {
- final char c = spec.charAt(i);
- if (c == '#') {
- if (dot >= 0 && i > dot) {
- decimals++;
- } else {
- slots++;
- }
- }
- }
- if (dollarFloat) {
- slots++; // the floating '$' claims one digit slot
- }
- final java.math.BigDecimal rounded = new java.math.BigDecimal(Math.abs(value))
- .setScale(decimals, java.math.RoundingMode.HALF_EVEN);
- final String plain = rounded.stripTrailingZeros().toPlainString();
- final String intDigits = plain.contains(".")
- ? plain.substring(0, plain.indexOf('.')) : plain;
- String fracDigits = decimals == 0 ? ""
- : String.format(java.util.Locale.ROOT, "%." + decimals + "f", rounded)
- .split("\\.")[1];
- if (intDigits.length() > slots) {
- // Overflow: '%' plus the unfielded number.
- return "%" + formatNumber(value);
- }
- // The field's total width is the format spec's character count;
- // a negative sign lives inside the field (no extra slot).
- final String sign = value < 0 ? "-" : "";
- final String digits = (dot >= 0 ? intDigits + "." + fracDigits : intDigits);
- final String core = sign + (dollar ? "$" : "") + digits;
- final int pad = Math.max(0, spec.length() - core.length());
- final String padded = (starFill ? "*" : " ").repeat(pad) + core;
- return trailingMinus && value < 0
- ? (starFill ? "*" : " ").repeat(pad) + digits + "-"
- : padded;
- }
-
/**
* Statically determines whether a PRINT expression has type DOUBLE:
* numbers are formatted by the expression's type (16 significant digits
/** ',' in PRINT: advance to the next 14-column print zone. */
private void printZoneAdvance() {
final int col = vga.cursorColumn();
- final int columns = vga.columns();
- final int target = ((col - 1) / 14 + 1) * 14 + 1;
- if (target > columns) {
+ final int target = PrintFormat.nextZone(col, vga.columns());
+ if (target < 0) {
vga.newLine();
} else {
vga.print(" ".repeat(target - col));
}
}
+ /** PRINT USING: the walk lives in PrintFormat; items evaluate here. */
+ private String usingFormatted(final Ast.PrintStmt s) {
+ return PrintFormat.usingFormat(str(eval(s.usingFormat()), s.line()),
+ s.items().size(),
+ i -> eval(s.items().get(i).expr()),
+ v -> str(v, s.line()),
+ v -> num(v, s.line()));
+ }
+
private void execLine(final Ast.LineStmt s) {
// Omitted start point: draw from the graphics cursor (QB45's
// LINE -(x, y) form). With -STEP the endpoint is relative to the start.
continue;
}
if (field.type().equalsIgnoreCase("STRING")) {
- String text = value instanceof String s ? s : formatNumber(num(value, line));
+ String text = value instanceof String s ? s : PrintFormat.singleNumber(num(value, line));
if (text.length() > field.stringLength()) {
text = text.substring(0, field.stringLength());
} else if (text.length() < field.stringLength()) {
}
case "STR$" -> {
final double v = num(singleArgValue(c), c.line());
- final String s = formatNumber(v);
+ final String s = PrintFormat.singleNumber(v);
return v >= 0 ? " " + s : s;
}
case "LEN" -> {
final Object value = eval(item.expr());
items.add(new IoSubsystem.PrintItem(
value instanceof String text ? text
- : basicNumber(num(value, s.line()), exprIsDouble(item.expr())),
+ : PrintFormat.number(num(value, s.line()), exprIsDouble(item.expr())),
item.separatorAfter()));
}
}
return vga.textBackground();
}
- /** PRINT formatting: leading space if non-negative, trailing space. */
- static String basicNumber(final double value) {
- return basicNumber(value, false);
- }
-
- /** PRINT formatting for a numeric expression of known static type. */
- static String basicNumber(final double value, final boolean isDouble) {
- final String s = isDouble ? formatDouble16(value) : formatNumber(value);
- return (value >= 0 ? " " + s : s) + " ";
- }
-
- /**
- * Formats a DOUBLE expression in the classic style: 16 significant
- * digits of the value's exact decimal expansion, rounded half-even at
- * the 17th digit, trailing zeros trimmed; scientific notation (D±xx)
- * outside the fixed range.
- *
- * <p>Known gap: some classic implementations of this dialect misprint
- * certain values by 1-2 ulp in a bit-pattern-dependent way (STR$(592#)
- * gives "591.9999999999999", STR$(500#) "499.9999999999999", but 321#
- * and 12714# print exactly there). The exact cause resists modeling —
- * every constant-error hypothesis fails on same-decade pairs
- * (300 vs 321, 321 vs 592). This formatter prints the mathematically
- * correct 16 digits instead.</p>
- */
- private static String formatDouble16(final double value) {
- if (value == 0) {
- return "0";
- }
- final String sign = value < 0 ? "-" : "";
- final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
- final java.math.BigDecimal rounded = bd.round(
- new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN));
- final int exponent = rounded.precision() - rounded.scale() - 1;
- final String digits = rounded.unscaledValue().toString();
- if (exponent > 15 || exponent < -3) {
- // Scientific: d.dddD±ee
- final String fraction = digits.substring(1).replaceFirst("0+$", "");
- final String mantissa = digits.charAt(0)
- + (fraction.isEmpty() ? "" : "." + fraction);
- return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-")
- + String.format("%02d", Math.abs(exponent));
- }
- if (exponent >= 0) {
- final String intPart = exponent + 1 >= digits.length()
- ? digits + "0".repeat(exponent + 1 - digits.length())
- : digits.substring(0, exponent + 1);
- final String fraction = exponent + 1 >= digits.length()
- ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
- return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
- }
- return sign + "." + "0".repeat(-exponent - 1)
- + digits.replaceFirst("0+$", "");
- }
-
- private static String formatNumber(final double value) {
- // Numbers are formatted at the expression's type; the default
- // SINGLE type means the value is narrowed to float32 for display.
- final double narrowed = single(value);
- if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed) && Math.abs(narrowed) < 1e15) {
- return Long.toString((long) narrowed);
- }
- return formatSingle7(narrowed);
- }
-
- /**
- * Formats a SINGLE in the classic style: 7 significant digits of the
- * value's exact float32 decimal expansion (rounded half-even at the 8th
- * digit), trailing zeros trimmed, no leading zero on fractions, E±xx
- * scientific notation outside the fixed range. PRINT VAL("12345.67")
- * therefore shows 12345.67, not the raw 12345.669921875 float.
- */
- private static String formatSingle7(final double value) {
- if (value == 0) {
- return "0";
- }
- final String sign = value < 0 ? "-" : "";
- final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
- final java.math.BigDecimal rounded = bd.round(
- new java.math.MathContext(7, java.math.RoundingMode.HALF_EVEN));
- final int exponent = rounded.precision() - rounded.scale() - 1;
- final String digits = rounded.unscaledValue().toString();
- if (exponent > 6 || exponent < -3) {
- final String fraction = digits.substring(1).replaceFirst("0+$", "");
- final String mantissa = digits.charAt(0)
- + (fraction.isEmpty() ? "" : "." + fraction);
- return sign + mantissa + "E" + (exponent >= 0 ? "+" : "-")
- + String.format("%02d", Math.abs(exponent));
- }
- if (exponent >= 0) {
- final String intPart = exponent + 1 >= digits.length()
- ? digits + "0".repeat(exponent + 1 - digits.length())
- : digits.substring(0, exponent + 1);
- final String fraction = exponent + 1 >= digits.length()
- ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
- return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
- }
- return sign + "." + "0".repeat(-exponent - 1)
- + digits.replaceFirst("0+$", "");
- }
-
private double throwDivisionByZero(final int line) {
throw error("Division by zero", line);
}
--- /dev/null
+package eu.svjatoslav.crtbasic.interp;
+
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.function.ToDoubleFunction;
+
+/**
+ * Classic PRINT formatting: the number style (leading space for
+ * non-negative, trailing space; ~7 significant digits for SINGLE, 16
+ * for DOUBLE, D±xx/E±xx scientific notation), PRINT USING field
+ * formatting, and the 14-column print-zone arithmetic shared by the
+ * console and file print paths.
+ */
+public final class PrintFormat {
+
+ private PrintFormat() {
+ // Utility class.
+ }
+
+ /** PRINT formatting: leading space if non-negative, trailing space. */
+ public static String number(final double value) {
+ return number(value, false);
+ }
+
+ /** PRINT formatting for a numeric expression of known static type. */
+ public static String number(final double value, final boolean isDouble) {
+ final String s = isDouble ? formatDouble16(value) : formatNumber(value);
+ return (value >= 0 ? " " + s : s) + " ";
+ }
+
+ /**
+ * Formats a DOUBLE expression in the classic style: 16 significant
+ * digits of the value's exact decimal expansion, rounded half-even at
+ * the 17th digit, trailing zeros trimmed; scientific notation (D±xx)
+ * outside the fixed range.
+ *
+ * <p>Known gap: some classic implementations of this dialect misprint
+ * certain values by 1-2 ulp in a bit-pattern-dependent way (STR$(592#)
+ * gives "591.9999999999999", STR$(500#) "499.9999999999999", but 321#
+ * and 12714# print exactly there). The exact cause resists modeling —
+ * every constant-error hypothesis fails on same-decade pairs
+ * (300 vs 321, 321 vs 592). This formatter prints the mathematically
+ * correct 16 digits instead.
+ */
+ private static String formatDouble16(final double value) {
+ if (value == 0) {
+ return "0";
+ }
+ final String sign = value < 0 ? "-" : "";
+ final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+ final java.math.BigDecimal rounded = bd.round(
+ new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN));
+ final int exponent = rounded.precision() - rounded.scale() - 1;
+ final String digits = rounded.unscaledValue().toString();
+ if (exponent > 15 || exponent < -3) {
+ // Scientific: d.dddD±ee
+ final String fraction = digits.substring(1).replaceFirst("0+$", "");
+ final String mantissa = digits.charAt(0)
+ + (fraction.isEmpty() ? "" : "." + fraction);
+ return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-")
+ + String.format("%02d", Math.abs(exponent));
+ }
+ if (exponent >= 0) {
+ final String intPart = exponent + 1 >= digits.length()
+ ? digits + "0".repeat(exponent + 1 - digits.length())
+ : digits.substring(0, exponent + 1);
+ final String fraction = exponent + 1 >= digits.length()
+ ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+ return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+ }
+ return sign + "." + "0".repeat(-exponent - 1)
+ + digits.replaceFirst("0+$", "");
+ }
+
+ private static String formatNumber(final double value) {
+ // Numbers are formatted at the expression's type; the default
+ // SINGLE type means the value is narrowed to float32 for display.
+ final double narrowed = (double) (float) value;
+ if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed)
+ && Math.abs(narrowed) < 1e15) {
+ return Long.toString((long) narrowed);
+ }
+ return formatSingle7(narrowed);
+ }
+
+ /**
+ * STR$ field content: the SINGLE-style format with no padding
+ * (leading/trailing spaces are the caller's business).
+ */
+ public static String singleNumber(final double value) {
+ return formatNumber(value);
+ }
+
+ /**
+ * Formats a SINGLE in the classic style: 7 significant digits of the
+ * value's exact float32 decimal expansion (rounded half-even at the 8th
+ * digit), trailing zeros trimmed, no leading zero on fractions, E±xx
+ * scientific notation outside the fixed range. PRINT VAL("12345.67")
+ * therefore shows 12345.67, not the raw 12345.669921875 float.
+ */
+ private static String formatSingle7(final double value) {
+ if (value == 0) {
+ return "0";
+ }
+ final String sign = value < 0 ? "-" : "";
+ final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+ final java.math.BigDecimal rounded = bd.round(
+ new java.math.MathContext(7, java.math.RoundingMode.HALF_EVEN));
+ final int exponent = rounded.precision() - rounded.scale() - 1;
+ final String digits = rounded.unscaledValue().toString();
+ if (exponent > 6 || exponent < -3) {
+ final String fraction = digits.substring(1).replaceFirst("0+$", "");
+ final String mantissa = digits.charAt(0)
+ + (fraction.isEmpty() ? "" : "." + fraction);
+ return sign + mantissa + "E" + (exponent >= 0 ? "+" : "-")
+ + String.format("%02d", Math.abs(exponent));
+ }
+ if (exponent >= 0) {
+ final String intPart = exponent + 1 >= digits.length()
+ ? digits + "0".repeat(exponent + 1 - digits.length())
+ : digits.substring(0, exponent + 1);
+ final String fraction = exponent + 1 >= digits.length()
+ ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+ return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+ }
+ return sign + "." + "0".repeat(-exponent - 1)
+ + digits.replaceFirst("0+$", "");
+ }
+
+ /**
+ * PRINT USING: walks the format left to right; literal characters print
+ * as-is ('_' escapes the next char), each format field consumes one
+ * item in order. String fields: '!' first char, '&' whole string,
+ * '\...\' fixed width. Numeric fields: runs of '#', '.', '$', '*',
+ * '+', '-', ',' — right-justified, decimals rounded half-even, '$$'
+ * floats the dollar sign, '*' fills with asterisks, overflow prints
+ * '%' plus the number. When items outlast the format, QB restarts the
+ * format after a newline; the current examples never do.
+ */
+ public static String usingFormat(final String format, final int itemCount,
+ final IntFunction<Object> itemValue,
+ final Function<Object, String> asString,
+ final ToDoubleFunction<Object> asNumber) {
+ final StringBuilder out = new StringBuilder();
+ int itemIndex = 0;
+ int pos = 0;
+ // Literals print as walked; a FIELD consumes the next item. The
+ // walk stops at the first field with no item left — trailing
+ // literals after the last consumed item still print.
+ while (pos < format.length()) {
+ final char c = format.charAt(pos);
+ if (c == '_' && pos + 1 < format.length()) {
+ out.append(format.charAt(pos + 1));
+ pos += 2;
+ continue;
+ }
+ final boolean isStringField = c == '!' || c == '&' || c == '\\';
+ final boolean isNumericField = "#.*$+-,".indexOf(c) >= 0;
+ if ((isStringField || isNumericField) && itemIndex >= itemCount) {
+ break;
+ }
+ if (c == '!' || c == '&') {
+ final String text = asString.apply(itemValue.apply(itemIndex));
+ out.append(c == '!' ? (text.isEmpty() ? " " : text.charAt(0)) : text);
+ itemIndex++;
+ pos++;
+ continue;
+ }
+ if (c == '\\') {
+ final int close = format.indexOf('\\', pos + 1);
+ final int width = close < 0 ? 2 : close - pos + 1;
+ final String text = asString.apply(itemValue.apply(itemIndex));
+ out.append(text.length() >= width ? text.substring(0, width)
+ : text + " ".repeat(width - text.length()));
+ itemIndex++;
+ pos = close < 0 ? format.length() : close + 1;
+ continue;
+ }
+ if ("#.*$+-,".indexOf(c) >= 0) {
+ final int start = pos;
+ while (pos < format.length() && "#.,$*^".indexOf(format.charAt(pos)) >= 0) {
+ pos++;
+ }
+ if (pos < format.length() && "+-".indexOf(format.charAt(pos)) >= 0) {
+ pos++;
+ }
+ out.append(numericField(format.substring(start, pos),
+ asNumber.applyAsDouble(itemValue.apply(itemIndex))));
+ itemIndex++;
+ continue;
+ }
+ out.append(c);
+ pos++;
+ }
+ return out.toString();
+ }
+
+ /** Formats one number into one PRINT USING numeric field. */
+ static String numericField(final String spec, final double value) {
+ final boolean starFill = spec.indexOf('*') >= 0;
+ final boolean dollar = spec.indexOf('$') >= 0;
+ final boolean dollarFloat = spec.startsWith("$$") || spec.contains("**$");
+ final boolean trailingMinus = spec.endsWith("-");
+ final int dot = spec.indexOf('.');
+ int slots = 0;
+ int decimals = 0;
+ for (int i = 0; i < spec.length(); i++) {
+ final char c = spec.charAt(i);
+ if (c == '#') {
+ if (dot >= 0 && i > dot) {
+ decimals++;
+ } else {
+ slots++;
+ }
+ }
+ }
+ if (dollarFloat) {
+ slots++; // the floating '$' claims one digit slot
+ }
+ final java.math.BigDecimal rounded = new java.math.BigDecimal(Math.abs(value))
+ .setScale(decimals, java.math.RoundingMode.HALF_EVEN);
+ final String plain = rounded.stripTrailingZeros().toPlainString();
+ final String intDigits = plain.contains(".")
+ ? plain.substring(0, plain.indexOf('.')) : plain;
+ String fracDigits = decimals == 0 ? ""
+ : String.format(java.util.Locale.ROOT, "%." + decimals + "f", rounded)
+ .split("\\.")[1];
+ if (intDigits.length() > slots) {
+ // Overflow: '%' plus the unfielded number.
+ return "%" + formatNumber(value);
+ }
+ // The field's total width is the format spec's character count;
+ // a negative sign lives inside the field (no extra slot).
+ final String sign = value < 0 ? "-" : "";
+ final String digits = (dot >= 0 ? intDigits + "." + fracDigits : intDigits);
+ final String core = sign + (dollar ? "$" : "") + digits;
+ final int pad = Math.max(0, spec.length() - core.length());
+ final String padded = (starFill ? "*" : " ").repeat(pad) + core;
+ return trailingMinus && value < 0
+ ? (starFill ? "*" : " ").repeat(pad) + digits + "-"
+ : padded;
+ }
+
+ /**
+ * ',' print-zone arithmetic: the next 14-column zone after
+ * {@code column}, or -1 when it would pass {@code width} (the console
+ * wraps at its column count, file output at QBasic's default 255).
+ */
+ public static int nextZone(final int column, final int width) {
+ final int target = ((column - 1) / 14 + 1) * 14 + 1;
+ return target > width ? -1 : target;
+ }
+}