From: Svjatoslav Agejenko Date: Thu, 3 Sep 2026 21:00:38 +0000 (+0300) Subject: refactor(interp): extract PRINT formatting into PrintFormat X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=0ef35bda5e4a7f6f18d0ca4f24a1cd72a6caee34;p=crtbasic.git refactor(interp): extract PRINT formatting into PrintFormat The classic number style (SINGLE 7-digit / DOUBLE 16-digit, D±xx/E±xx scientific, sign padding), the PRINT USING field walker, and the 14-column zone arithmetic lived in Interpreter; the zone math was duplicated in two places with different wrap rules (console wraps at its column count, file output at 255). PrintFormat now owns all of it: number()/singleNumber(), usingFormat() with an item-value callback (items still evaluate in the interpreter), numericField(), and nextZone() shared by the console printZoneAdvance and IoSubsystem's PRINT # zones — the wrap rule is now an explicit parameter at both call sites instead of two divergent inline copies. Verification: 93/93 tests green; CHECK (PRINT USING), FLPT (STR$/HEX$) and BAR (PRINT USING + records) re-run pixel-identical. --- diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 454fabb..def421b 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -1228,7 +1228,7 @@ public final class Interpreter { 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(); } @@ -1238,119 +1238,6 @@ public final class Interpreter { } } - /** - * 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 @@ -1369,15 +1256,23 @@ public final class Interpreter { /** ',' 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. @@ -1794,7 +1689,7 @@ public final class Interpreter { 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()) { @@ -2189,7 +2084,7 @@ public final class Interpreter { } 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" -> { @@ -2907,7 +2802,7 @@ public final class Interpreter { 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())); } } @@ -2938,107 +2833,6 @@ public final class Interpreter { 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. - * - *

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 = 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); } diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java b/src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java new file mode 100644 index 0000000..4a69532 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/PrintFormat.java @@ -0,0 +1,253 @@ +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. + * + *

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 itemValue, + final Function asString, + final ToDoubleFunction 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; + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java b/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java index 32f87cb..b5308f2 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java +++ b/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java @@ -1,6 +1,7 @@ package eu.svjatoslav.crtbasic.io; import eu.svjatoslav.crtbasic.Cp437; +import eu.svjatoslav.crtbasic.interp.PrintFormat; import java.util.HashMap; import java.util.List; @@ -359,8 +360,8 @@ public final class IoSubsystem { channel.writer.write(item.text()); channel.column += item.text().length(); if (item.separatorAfter() == ',') { - final int target = ((channel.column - 1) / 14 + 1) * 14 + 1; - if (target > 255) { + final int target = PrintFormat.nextZone(channel.column, 255); + if (target < 0) { channel.writer.write("\r\n"); channel.column = 1; } else {