From 5965c69ee843aef4ed21c9c5c88b4cd557405f80 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 18 Jun 2026 22:10:55 +0200 Subject: [PATCH 1/2] refactor(cli): extract pure render helpers from TUI loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the stateless string formatters out of VortexInspectorTui$Loop and LazyGridSource into InspectorRender and GridRender so they can be unit-tested against in-memory arrays — no terminal, IoWorker, or encoded fixture needed: - InspectorRender: formatValue, formatStatsArray, formatHexRow, formatBytes, pad, truncate - GridRender: formatCell (+ extension/hex helpers) - ArrayFixtures (test): builds small Materialized* arrays from an Arena - InspectorRenderTest / GridRenderTest: direct per-type + edge-case coverage LazyGridSource coverage 66.5% -> 86.7%; render logic now isolated from I/O. Co-Authored-By: Claude Opus 4.8 --- .../dfa1/vortex/cli/tui/GridRender.java | 106 ++++++++++ .../dfa1/vortex/cli/tui/InspectorRender.java | 199 ++++++++++++++++++ .../dfa1/vortex/cli/tui/LazyGridSource.java | 83 +------- .../vortex/cli/tui/VortexInspectorTui.java | 181 ++-------------- .../dfa1/vortex/cli/tui/ArrayFixtures.java | 92 ++++++++ .../dfa1/vortex/cli/tui/GridRenderTest.java | 46 ++++ .../vortex/cli/tui/InspectorRenderTest.java | 103 +++++++++ 7 files changed, 562 insertions(+), 248 deletions(-) create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/tui/GridRender.java create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/tui/InspectorRender.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/tui/ArrayFixtures.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/tui/GridRenderTest.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/tui/InspectorRenderTest.java diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/GridRender.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/GridRender.java new file mode 100644 index 00000000..b8a22dae --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/GridRender.java @@ -0,0 +1,106 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.extension.ExtensionId; +import io.github.dfa1.vortex.reader.array.Array; +import io.github.dfa1.vortex.reader.array.BoolArray; +import io.github.dfa1.vortex.reader.array.ByteArray; +import io.github.dfa1.vortex.reader.array.DecimalArray; +import io.github.dfa1.vortex.reader.array.DoubleArray; +import io.github.dfa1.vortex.reader.array.FloatArray; +import io.github.dfa1.vortex.reader.array.IntArray; +import io.github.dfa1.vortex.reader.array.LongArray; +import io.github.dfa1.vortex.reader.array.MaskedArray; +import io.github.dfa1.vortex.reader.array.ShortArray; +import io.github.dfa1.vortex.reader.array.VarBinArray; +import io.github.dfa1.vortex.reader.extension.DateExtensionDecoder; +import io.github.dfa1.vortex.reader.extension.TimeExtensionDecoder; +import io.github.dfa1.vortex.reader.extension.TimestampExtensionDecoder; +import io.github.dfa1.vortex.reader.extension.UuidExtensionDecoder; + +import java.util.Optional; + +/// Pure cell-formatting helpers for the grid viewer. Stateless: turns one decoded +/// array element into the display string shown in a grid cell. Extracted from +/// [LazyGridSource] so the per-type and per-extension branches can be unit-tested +/// against in-memory arrays without a terminal or an encoded fixture. +final class GridRender { + + private GridRender() { + } + + /// Formats the element at logical index `i` of `array` for display. + /// + /// Returns the empty string for out-of-range indices and masked-out (null) + /// cells. Extension types (date/time/timestamp/uuid) are rendered via their + /// decoders; on any decode failure an angle-bracketed diagnostic is returned. + /// + /// @param array the column array (may be a [MaskedArray]) + /// @param i logical element index + /// @param declared declared logical type (drives extension rendering) + /// @return formatted cell text + static String formatCell(Array array, long i, DType declared) { + if (array == null || i >= array.length()) { + return ""; + } + if (array instanceof MaskedArray m && !m.isValid(i)) { + return ""; + } + Array inner = array instanceof MaskedArray m ? m.inner() : array; + if (i >= inner.length()) { + return ""; + } + try { + if (declared instanceof DType.Extension ext) { + Optional extFormatted = formatExtension(ext, inner, i); + if (extFormatted.isPresent()) { + return extFormatted.get(); + } + } + return switch (inner) { + case LongArray a -> Long.toString(a.getLong(i)); + case IntArray a -> Integer.toString(a.getInt(i)); + case ShortArray a -> Short.toString(a.getShort(i)); + case ByteArray a -> Byte.toString(a.getByte(i)); + case DoubleArray a -> Double.toString(a.getDouble(i)); + case FloatArray a -> Float.toString(a.getFloat(i)); + case BoolArray a -> Boolean.toString(a.getBoolean(i)); + case VarBinArray a -> a.dtype() instanceof DType.Utf8 + ? a.getString(i) + : bytesToHex(a.getBytes(i)); + case DecimalArray a -> a.getDecimal(i).toPlainString(); + default -> "<" + inner.getClass().getSimpleName() + ">"; + }; + } catch (RuntimeException e) { + String msg = e.getMessage(); + return "<" + e.getClass().getSimpleName() + + (msg != null ? ": " + msg.split("\n", 2)[0] : "") + ">"; + } + } + + private static Optional formatExtension(DType.Extension ext, Array storage, long i) { + Optional idOpt = ExtensionId.parse(ext.extensionId()); + if (idOpt.isEmpty()) { + return Optional.empty(); + } + return Optional.of(switch (idOpt.get()) { + case VORTEX_DATE -> DateExtensionDecoder.INSTANCE.decode(storage, i).toString(); + case VORTEX_TIME -> TimeExtensionDecoder.INSTANCE.decode(ext, storage, i).toString(); + case VORTEX_TIMESTAMP -> TimestampExtensionDecoder.INSTANCE.instant(ext, storage, i).toString(); + case VORTEX_UUID -> UuidExtensionDecoder.INSTANCE.decode(storage, i).toString(); + }); + } + + private static String bytesToHex(byte[] bytes) { + int n = Math.min(bytes.length, 16); + StringBuilder sb = new StringBuilder(n * 2 + 2); + sb.append("0x"); + for (int i = 0; i < n; i++) { + sb.append(String.format("%02x", bytes[i] & 0xff)); + } + if (bytes.length > n) { + sb.append("..."); + } + return sb.toString(); + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/InspectorRender.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/InspectorRender.java new file mode 100644 index 00000000..95850e8e --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/InspectorRender.java @@ -0,0 +1,199 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.extension.ExtensionId; +import io.github.dfa1.vortex.reader.array.Array; +import io.github.dfa1.vortex.reader.array.BoolArray; +import io.github.dfa1.vortex.reader.array.ByteArray; +import io.github.dfa1.vortex.reader.array.DecimalArray; +import io.github.dfa1.vortex.reader.array.DoubleArray; +import io.github.dfa1.vortex.reader.array.FloatArray; +import io.github.dfa1.vortex.reader.array.GenericArray; +import io.github.dfa1.vortex.reader.array.IntArray; +import io.github.dfa1.vortex.reader.array.LongArray; +import io.github.dfa1.vortex.reader.array.MaskedArray; +import io.github.dfa1.vortex.reader.array.ShortArray; +import io.github.dfa1.vortex.reader.array.StructArray; +import io.github.dfa1.vortex.reader.array.VarBinArray; +import io.github.dfa1.vortex.reader.extension.DateExtensionDecoder; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.function.LongFunction; + +/// Pure rendering helpers for [VortexInspectorTui]. Stateless string formatters +/// extracted from the event loop so they can be unit-tested against in-memory +/// arrays without a terminal, an [IoWorker], or an encoded fixture file. +final class InspectorRender { + + private InspectorRender() { + } + + /// Formats one array element for the detail/data preview. + /// + /// @param array array holding the value + /// @param i element index + /// @param declared declared logical type (drives extension rendering) + /// @return formatted value, or an angle-bracketed fallback for unknown shapes + static String formatValue(Array array, int i, DType declared) { + if (declared instanceof DType.Extension ext + && ExtensionId.parse(ext.extensionId()) + .filter(id -> id == ExtensionId.VORTEX_DATE) + .isPresent()) { + try { + return DateExtensionDecoder.INSTANCE.decode(array, i).toString(); + } catch (RuntimeException e) { + // fall through to generic rendering on shape mismatch + } + } + return switch (array) { + case LongArray a -> Long.toString(a.getLong(i)); + case IntArray a -> Integer.toString(a.getInt(i)); + case ShortArray a -> Short.toString(a.getShort(i)); + case ByteArray a -> Byte.toString(a.getByte(i)); + case DoubleArray a -> Double.toString(a.getDouble(i)); + case FloatArray a -> Float.toString(a.getFloat(i)); + case BoolArray a -> Boolean.toString(a.getBoolean(i)); + case VarBinArray a -> a.dtype() instanceof DType.Utf8 + ? "\"" + a.getString(i) + "\"" + : bytesToShortHex(a.getBytes(i)); + case GenericArray a when a.dtype() instanceof DType.Decimal -> + tryDecimal(a::getDecimal, a, i); + case DecimalArray a -> tryDecimal(a::getDecimal, a, i); + default -> "<" + array.getClass().getSimpleName() + " " + array.dtype() + ">"; + }; + } + + /// Formats one struct of decoded zone-map statistics into a single display row. + /// + /// @param arr stats array (possibly wrapped in a [MaskedArray]) + /// @param statsDtype struct schema describing the stats fields + /// @return one `"field=value, ..."` string per stats row + static List formatStatsArray(Array arr, DType.Struct statsDtype) { + Array unwrapped = arr instanceof MaskedArray m ? m.inner() : arr; + if (!(unwrapped instanceof StructArray sa)) { + throw new IllegalStateException( + "stats array is not a struct: " + arr.getClass().getSimpleName()); + } + int n = (int) sa.length(); + List rows = new ArrayList<>(n); + for (int row = 0; row < n; row++) { + StringBuilder sb = new StringBuilder(); + for (int f = 0; f < sa.fieldCount(); f++) { + if (f > 0) { + sb.append(", "); + } + String name = statsDtype.fieldNames().get(f); + DType fdtype = statsDtype.fieldTypes().get(f); + Array field = sa.field(f); + sb.append(name).append('=').append(formatStatsCell(field, row, fdtype)); + } + rows.add(sb.toString()); + } + return rows; + } + + private static String formatStatsCell(Array field, int row, DType declared) { + if (field instanceof MaskedArray m) { + if (!m.isValid(row)) { + return "null"; + } + return formatValue(m.inner(), row, declared); + } + return formatValue(field, row, declared); + } + + private static String tryDecimal(LongFunction reader, Array a, int i) { + try { + return reader.apply(i).toPlainString(); + } catch (RuntimeException e) { + String msg = e.getMessage(); + if (msg != null && msg.contains("null cell")) { + return "null"; + } + return "<" + a.getClass().getSimpleName() + " " + a.dtype() + ">"; + } + } + + private static String bytesToShortHex(byte[] bytes) { + int n = Math.min(bytes.length, 16); + StringBuilder sb = new StringBuilder(n * 3 + 2); + sb.append("0x"); + for (int i = 0; i < n; i++) { + sb.append(String.format("%02x", bytes[i] & 0xff)); + } + if (bytes.length > n) { + sb.append("..."); + } + return sb.toString(); + } + + /// Formats one 16-byte row of a hex dump: offset, hex columns, ASCII gutter. + /// + /// @param data the bytes being dumped + /// @param offset start offset of this row within `data` + /// @return the formatted `"%08x .. .. | .... |"` line + static String formatHexRow(byte[] data, int offset) { + StringBuilder sb = new StringBuilder(80); + sb.append(String.format("%08x ", offset)); + for (int i = 0; i < 16; i++) { + int idx = offset + i; + if (idx < data.length) { + sb.append(String.format("%02x ", data[idx] & 0xff)); + } else { + sb.append(" "); + } + if (i == 7) { + sb.append(' '); + } + } + sb.append(" |"); + for (int i = 0; i < 16; i++) { + int idx = offset + i; + if (idx >= data.length) { + sb.append(' '); + continue; + } + int b = data[idx] & 0xff; + sb.append(b >= 0x20 && b < 0x7f ? (char) b : '.'); + } + sb.append('|'); + return sb.toString(); + } + + /// Formats a byte count as B / KB / MB. + /// + /// @param bytes raw byte count + /// @return human-readable size string + static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024.0); + } + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + + /// Pads or truncates `s` to exactly `width` characters. + /// + /// @param s source string + /// @param width target width + /// @return a string of length `width` + static String pad(String s, int width) { + if (s.length() >= width) { + return s.substring(0, width); + } + return s + " ".repeat(width - s.length()); + } + + /// Truncates `s` to at most `width` characters. + /// + /// @param s source string + /// @param width maximum width + /// @return `s` unchanged if short enough, otherwise its first `width` characters + static String truncate(String s, int width) { + return s.length() > width ? s.substring(0, width) : s; + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/LazyGridSource.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/LazyGridSource.java index 97eb373e..ff40fa4c 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/LazyGridSource.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/LazyGridSource.java @@ -2,29 +2,13 @@ import io.github.dfa1.vortex.core.DType; import io.github.dfa1.vortex.core.VortexException; -import io.github.dfa1.vortex.extension.ExtensionId; import io.github.dfa1.vortex.reader.Chunk; import io.github.dfa1.vortex.reader.ScanIterator; import io.github.dfa1.vortex.reader.ScanOptions; import io.github.dfa1.vortex.reader.VortexHandle; import io.github.dfa1.vortex.reader.array.Array; -import io.github.dfa1.vortex.reader.array.BoolArray; -import io.github.dfa1.vortex.reader.array.ByteArray; -import io.github.dfa1.vortex.reader.array.DoubleArray; -import io.github.dfa1.vortex.reader.array.FloatArray; -import io.github.dfa1.vortex.reader.array.IntArray; -import io.github.dfa1.vortex.reader.array.DecimalArray; -import io.github.dfa1.vortex.reader.array.LongArray; -import io.github.dfa1.vortex.reader.array.MaskedArray; -import io.github.dfa1.vortex.reader.array.ShortArray; -import io.github.dfa1.vortex.reader.array.VarBinArray; -import io.github.dfa1.vortex.reader.extension.DateExtensionDecoder; -import io.github.dfa1.vortex.reader.extension.TimeExtensionDecoder; -import io.github.dfa1.vortex.reader.extension.TimestampExtensionDecoder; -import io.github.dfa1.vortex.reader.extension.UuidExtensionDecoder; import java.util.List; -import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; /// Streaming row source for the grid viewer. @@ -269,7 +253,7 @@ private String[] formatRow(Array[] arrays, long inChunk) { int n = columns.size(); String[] row = new String[n]; for (int c = 0; c < n; c++) { - row[c] = formatCell(arrays[c], inChunk, columnDtypes.get(c)); + row[c] = GridRender.formatCell(arrays[c], inChunk, columnDtypes.get(c)); } return row; } @@ -278,71 +262,6 @@ private String[] emptyRow() { return new String[columns.size()]; } - private static String formatCell(Array array, long i, DType declared) { - if (array == null || i >= array.length()) { - return ""; - } - if (array instanceof MaskedArray m && !m.isValid(i)) { - return ""; - } - Array inner = array instanceof MaskedArray m ? m.inner() : array; - if (i >= inner.length()) { - return ""; - } - try { - if (declared instanceof DType.Extension ext) { - Optional extFormatted = formatExtension(ext, inner, i); - if (extFormatted.isPresent()) { - return extFormatted.get(); - } - } - return switch (inner) { - case LongArray a -> Long.toString(a.getLong(i)); - case IntArray a -> Integer.toString(a.getInt(i)); - case ShortArray a -> Short.toString(a.getShort(i)); - case ByteArray a -> Byte.toString(a.getByte(i)); - case DoubleArray a -> Double.toString(a.getDouble(i)); - case FloatArray a -> Float.toString(a.getFloat(i)); - case BoolArray a -> Boolean.toString(a.getBoolean(i)); - case VarBinArray a -> a.dtype() instanceof DType.Utf8 - ? a.getString(i) - : bytesToHex(a.getBytes(i)); - case DecimalArray a -> a.getDecimal(i).toPlainString(); - default -> "<" + inner.getClass().getSimpleName() + ">"; - }; - } catch (RuntimeException e) { - String msg = e.getMessage(); - return "<" + e.getClass().getSimpleName() - + (msg != null ? ": " + msg.split("\n", 2)[0] : "") + ">"; - } - } - - private static Optional formatExtension(DType.Extension ext, Array storage, long i) { - Optional idOpt = ExtensionId.parse(ext.extensionId()); - if (idOpt.isEmpty()) { - return Optional.empty(); - } - return Optional.of(switch (idOpt.get()) { - case VORTEX_DATE -> DateExtensionDecoder.INSTANCE.decode(storage, i).toString(); - case VORTEX_TIME -> TimeExtensionDecoder.INSTANCE.decode(ext, storage, i).toString(); - case VORTEX_TIMESTAMP -> TimestampExtensionDecoder.INSTANCE.instant(ext, storage, i).toString(); - case VORTEX_UUID -> UuidExtensionDecoder.INSTANCE.decode(storage, i).toString(); - }); - } - - private static String bytesToHex(byte[] bytes) { - int n = Math.min(bytes.length, 16); - StringBuilder sb = new StringBuilder(n * 2 + 2); - sb.append("0x"); - for (int i = 0; i < n; i++) { - sb.append(String.format("%02x", bytes[i] & 0xff)); - } - if (bytes.length > n) { - sb.append("..."); - } - return sb.toString(); - } - @Override public void close() { if (closed) { diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java index 106b7699..4d93837e 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java @@ -4,16 +4,6 @@ import io.github.dfa1.vortex.reader.SegmentSpec; import io.github.dfa1.vortex.core.DType; import io.github.dfa1.vortex.reader.array.Array; -import io.github.dfa1.vortex.reader.array.BoolArray; -import io.github.dfa1.vortex.reader.array.ByteArray; -import io.github.dfa1.vortex.reader.array.DoubleArray; -import io.github.dfa1.vortex.reader.array.FloatArray; -import io.github.dfa1.vortex.reader.array.GenericArray; -import io.github.dfa1.vortex.reader.array.IntArray; -import io.github.dfa1.vortex.reader.array.DecimalArray; -import io.github.dfa1.vortex.reader.array.LongArray; -import io.github.dfa1.vortex.reader.array.ShortArray; -import io.github.dfa1.vortex.reader.array.VarBinArray; import io.github.dfa1.vortex.cli.tui.term.Ansi; import io.github.dfa1.vortex.cli.tui.term.Key; import io.github.dfa1.vortex.cli.tui.term.Terminal; @@ -23,7 +13,6 @@ import io.github.dfa1.vortex.reader.Chunk; import io.github.dfa1.vortex.reader.ScanIterator; import io.github.dfa1.vortex.reader.ScanOptions; -import io.github.dfa1.vortex.reader.array.StructArray; import java.io.IOException; import java.lang.foreign.MemorySegment; @@ -397,26 +386,26 @@ private void drawStatus(StringBuilder buf, int width, int row) { } buf.append(Ansi.moveTo(row, 1)); buf.append(Ansi.bg(bg)).append(Ansi.fg(30)); - buf.append(pad(text, width)); + buf.append(InspectorRender.pad(text, width)); buf.append(Ansi.RESET); } private void drawHeader(StringBuilder buf, int width) { String header = " vortex-inspect — v" + tree.version() - + " " + formatBytes(tree.fileSize()) + + " " + InspectorRender.formatBytes(tree.fileSize()) + " rows=" + tree.totalRowCount() + " segs=" + tree.segmentCount() - + " (" + formatBytes(tree.totalSegmentBytes()) + ")"; + + " (" + InspectorRender.formatBytes(tree.totalSegmentBytes()) + ")"; buf.append(Ansi.moveTo(1, 1)); buf.append(Ansi.bg(46)).append(Ansi.fg(30)); - buf.append(pad(header, width)); + buf.append(InspectorRender.pad(header, width)); buf.append(Ansi.RESET); } private void drawFooter(StringBuilder buf, int width, int height) { buf.append(Ansi.moveTo(height, 1)); buf.append(Ansi.bg(47)).append(Ansi.fg(30)); - buf.append(pad(" ↑↓ nav →/Enter expand ← collapse q quit ", width)); + buf.append(InspectorRender.pad(" ↑↓ nav →/Enter expand ← collapse q quit ", width)); buf.append(Ansi.RESET); } @@ -425,7 +414,7 @@ private void drawTree(StringBuilder buf, List items, int top, int rows, in int idx = scrollOffset + row; buf.append(Ansi.moveTo(top + row + 1, 1)); if (idx >= items.size()) { - buf.append(pad("", leftWidth - 1)); + buf.append(InspectorRender.pad("", leftWidth - 1)); continue; } Item item = items.get(idx); @@ -433,7 +422,7 @@ private void drawTree(StringBuilder buf, List items, int top, int rows, in if (isSelected) { buf.append(Ansi.bg(43)).append(Ansi.fg(30)); } - buf.append(pad(renderItem(item), leftWidth - 1)); + buf.append(InspectorRender.pad(renderItem(item), leftWidth - 1)); if (isSelected) { buf.append(Ansi.RESET); } @@ -469,7 +458,7 @@ private void drawDetails(StringBuilder buf, InspectorTree.Node node, List lines = detailLines(node); for (int i = 0; i < lines.size() && i < rows; i++) { buf.append(Ansi.moveTo(top + i + 1, col + 1)); - buf.append(truncate(lines.get(i), width)); + buf.append(InspectorRender.truncate(lines.get(i), width)); } } @@ -491,7 +480,7 @@ private List detailLines(InspectorTree.Node node) { subtotal += tree.segmentSpecs().get(idx).length(); } lines.add("Segments: " + layout.segments().size() - + " (" + formatBytes(subtotal) + ")"); + + " (" + InspectorRender.formatBytes(subtotal) + ")"); long rows = layout.rowCount(); for (int idx : layout.segments()) { SegmentSpec spec = tree.segmentSpecs().get(idx); @@ -499,7 +488,7 @@ private List detailLines(InspectorTree.Node node) { ? " bits/elem=" + String.format("%.2f", spec.length() * 8.0 / rows) : ""; lines.add(" [" + idx + "] off=" + spec.offset() - + " len=" + formatBytes(spec.length()) + + " len=" + InspectorRender.formatBytes(spec.length()) + " compression=" + spec.compression().name() + bits); } @@ -571,9 +560,9 @@ private List detailLines(InspectorTree.Node node) { int segIdx = layout.segments().getFirst(); SegmentSpec spec = tree.segmentSpecs().get(segIdx); lines.add("Hex (first " + preview.length + " B of segment " - + segIdx + ", total " + formatBytes(spec.length()) + "):"); + + segIdx + ", total " + InspectorRender.formatBytes(spec.length()) + "):"); for (int off = 0; off < preview.length; off += 16) { - lines.add(formatHexRow(preview, off)); + lines.add(InspectorRender.formatHexRow(preview, off)); } } } @@ -621,7 +610,7 @@ private void runDictLoad(InspectorTree.Node dictNode) { int n = (int) Math.min(arr.length(), DATA_PREVIEW_ROWS); List out = new ArrayList<>(n); for (int i = 0; i < n; i++) { - out.add(formatValue(arr, i, dtype)); + out.add(InspectorRender.formatValue(arr, i, dtype)); } dictCache.put(dictNode, new DataState.Loaded(List.copyOf(out))); } @@ -734,43 +723,7 @@ private List decodeStatsFlat( int segIdx = flat.segments().getFirst(); SegmentSpec spec = tree.segmentSpecs().get(segIdx); Array arr = handle.decodeFlatSegment(spec, statsDtype, flat.rowCount(), arena); - return formatStatsArray(arr, statsDtype); - } - - private static List formatStatsArray(Array arr, DType.Struct statsDtype) { - Array unwrapped = arr instanceof io.github.dfa1.vortex.reader.array.MaskedArray m - ? m.inner() - : arr; - if (!(unwrapped instanceof StructArray sa)) { - throw new IllegalStateException( - "stats array is not a struct: " + arr.getClass().getSimpleName()); - } - int n = (int) sa.length(); - List rows = new ArrayList<>(n); - for (int row = 0; row < n; row++) { - StringBuilder sb = new StringBuilder(); - for (int f = 0; f < sa.fieldCount(); f++) { - if (f > 0) { - sb.append(", "); - } - String name = statsDtype.fieldNames().get(f); - DType fdtype = statsDtype.fieldTypes().get(f); - Array field = sa.field(f); - sb.append(name).append('=').append(formatStatsCell(field, row, fdtype)); - } - rows.add(sb.toString()); - } - return rows; - } - - private static String formatStatsCell(Array field, int row, DType declared) { - if (field instanceof io.github.dfa1.vortex.reader.array.MaskedArray m) { - if (!m.isValid(row)) { - return "null"; - } - return formatValue(m.inner(), row, declared); - } - return formatValue(field, row, declared); + return InspectorRender.formatStatsArray(arr, statsDtype); } private DType columnDtypeFor(InspectorTree.Node node) { @@ -821,7 +774,7 @@ private void runDataLoad(String columnName) { int n = (int) Math.min(array.length(), DATA_PREVIEW_ROWS); List out = new ArrayList<>(n); for (int i = 0; i < n; i++) { - out.add(formatValue(array, i, declared)); + out.add(InspectorRender.formatValue(array, i, declared)); } dataCache.put(columnName, new DataState.Loaded(List.copyOf(out))); } @@ -861,61 +814,6 @@ record Failed(String message) implements DataState { } } - private static String formatValue(Array array, int i, DType declared) { - if (declared instanceof DType.Extension ext - && io.github.dfa1.vortex.extension.ExtensionId.parse(ext.extensionId()) - .filter(id -> id == io.github.dfa1.vortex.extension.ExtensionId.VORTEX_DATE) - .isPresent()) { - try { - return io.github.dfa1.vortex.reader.extension.DateExtensionDecoder.INSTANCE.decode(array, i).toString(); - } catch (RuntimeException e) { - // fall through to generic rendering on shape mismatch - } - } - return switch (array) { - case LongArray a -> Long.toString(a.getLong(i)); - case IntArray a -> Integer.toString(a.getInt(i)); - case ShortArray a -> Short.toString(a.getShort(i)); - case ByteArray a -> Byte.toString(a.getByte(i)); - case DoubleArray a -> Double.toString(a.getDouble(i)); - case FloatArray a -> Float.toString(a.getFloat(i)); - case BoolArray a -> Boolean.toString(a.getBoolean(i)); - case VarBinArray a -> a.dtype() instanceof DType.Utf8 - ? "\"" + a.getString(i) + "\"" - : bytesToShortHex(a.getBytes(i)); - case GenericArray a when a.dtype() instanceof DType.Decimal -> - tryDecimal(a::getDecimal, a, i); - case DecimalArray a -> tryDecimal(a::getDecimal, a, i); - default -> "<" + array.getClass().getSimpleName() + " " + array.dtype() + ">"; - }; - } - - private static String tryDecimal(java.util.function.LongFunction reader, - Array a, int i) { - try { - return reader.apply(i).toPlainString(); - } catch (RuntimeException e) { - String msg = e.getMessage(); - if (msg != null && msg.contains("null cell")) { - return "null"; - } - return "<" + a.getClass().getSimpleName() + " " + a.dtype() + ">"; - } - } - - private static String bytesToShortHex(byte[] bytes) { - int n = Math.min(bytes.length, 16); - StringBuilder sb = new StringBuilder(n * 3 + 2); - sb.append("0x"); - for (int i = 0; i < n; i++) { - sb.append(String.format("%02x", bytes[i] & 0xff)); - } - if (bytes.length > n) { - sb.append("..."); - } - return sb.toString(); - } - private byte[] loadHexPreview(InspectorTree.Node node) { byte[] cached = hexCache.get(node); if (cached != null) { @@ -957,56 +855,7 @@ private byte[] fetchHex(InspectorTree.Node node) { } } - private static String formatHexRow(byte[] data, int offset) { - StringBuilder sb = new StringBuilder(80); - sb.append(String.format("%08x ", offset)); - for (int i = 0; i < 16; i++) { - int idx = offset + i; - if (idx < data.length) { - sb.append(String.format("%02x ", data[idx] & 0xff)); - } else { - sb.append(" "); - } - if (i == 7) { - sb.append(' '); - } - } - sb.append(" |"); - for (int i = 0; i < 16; i++) { - int idx = offset + i; - if (idx >= data.length) { - sb.append(' '); - continue; - } - int b = data[idx] & 0xff; - sb.append(b >= 0x20 && b < 0x7f ? (char) b : '.'); - } - sb.append('|'); - return sb.toString(); - } - private record Item(InspectorTree.Node node, int depth) { } - - private static String pad(String s, int width) { - if (s.length() >= width) { - return s.substring(0, width); - } - return s + " ".repeat(width - s.length()); - } - - private static String truncate(String s, int width) { - return s.length() > width ? s.substring(0, width) : s; - } - - private static String formatBytes(long bytes) { - if (bytes < 1024) { - return bytes + " B"; - } - if (bytes < 1024 * 1024) { - return String.format("%.1f KB", bytes / 1024.0); - } - return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); - } } } diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/ArrayFixtures.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/ArrayFixtures.java new file mode 100644 index 00000000..ab607500 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/ArrayFixtures.java @@ -0,0 +1,92 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.core.PType; +import io.github.dfa1.vortex.reader.array.BoolArray; +import io.github.dfa1.vortex.reader.array.ByteArray; +import io.github.dfa1.vortex.reader.array.DoubleArray; +import io.github.dfa1.vortex.reader.array.FloatArray; +import io.github.dfa1.vortex.reader.array.IntArray; +import io.github.dfa1.vortex.reader.array.LongArray; +import io.github.dfa1.vortex.reader.array.MaterializedBoolArray; +import io.github.dfa1.vortex.reader.array.MaterializedByteArray; +import io.github.dfa1.vortex.reader.array.MaterializedDoubleArray; +import io.github.dfa1.vortex.reader.array.MaterializedFloatArray; +import io.github.dfa1.vortex.reader.array.MaterializedIntArray; +import io.github.dfa1.vortex.reader.array.MaterializedLongArray; +import io.github.dfa1.vortex.reader.array.MaterializedShortArray; +import io.github.dfa1.vortex.reader.array.ShortArray; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; + +/// Builds small in-memory `Materialized*` arrays from a confined [Arena] for the +/// render-helper unit tests. Each builder mirrors the on-wire little-endian layout +/// the reader produces so the formatters see realistic inputs. +final class ArrayFixtures { + + private ArrayFixtures() { + } + + static LongArray longs(Arena arena, long... vs) { + MemorySegment seg = arena.allocate(vs.length * 8L, 8); + for (int i = 0; i < vs.length; i++) { + seg.setAtIndex(ValueLayout.JAVA_LONG, i, vs[i]); + } + return new MaterializedLongArray(new DType.Primitive(PType.I64, false), vs.length, seg.asReadOnly()); + } + + static IntArray ints(Arena arena, int... vs) { + MemorySegment seg = arena.allocate(vs.length * 4L, 4); + for (int i = 0; i < vs.length; i++) { + seg.setAtIndex(ValueLayout.JAVA_INT, i, vs[i]); + } + return new MaterializedIntArray(new DType.Primitive(PType.I32, false), vs.length, seg.asReadOnly()); + } + + static ShortArray shorts(Arena arena, short... vs) { + MemorySegment seg = arena.allocate(vs.length * 2L, 2); + for (int i = 0; i < vs.length; i++) { + seg.setAtIndex(ValueLayout.JAVA_SHORT, i, vs[i]); + } + return new MaterializedShortArray(new DType.Primitive(PType.I16, false), vs.length, seg.asReadOnly()); + } + + static ByteArray bytes(Arena arena, byte... vs) { + MemorySegment seg = arena.allocate(vs.length, 1); + for (int i = 0; i < vs.length; i++) { + seg.set(ValueLayout.JAVA_BYTE, i, vs[i]); + } + return new MaterializedByteArray(new DType.Primitive(PType.I8, false), vs.length, seg.asReadOnly()); + } + + static DoubleArray doubles(Arena arena, double... vs) { + MemorySegment seg = arena.allocate(vs.length * 8L, 8); + for (int i = 0; i < vs.length; i++) { + seg.setAtIndex(ValueLayout.JAVA_DOUBLE, i, vs[i]); + } + return new MaterializedDoubleArray(new DType.Primitive(PType.F64, false), vs.length, seg.asReadOnly()); + } + + static FloatArray floats(Arena arena, float... vs) { + MemorySegment seg = arena.allocate(vs.length * 4L, 4); + for (int i = 0; i < vs.length; i++) { + seg.setAtIndex(ValueLayout.JAVA_FLOAT, i, vs[i]); + } + return new MaterializedFloatArray(new DType.Primitive(PType.F32, false), vs.length, seg.asReadOnly()); + } + + static BoolArray bools(Arena arena, boolean... vs) { + int bytes = (vs.length + 7) / 8; + MemorySegment seg = arena.allocate(Math.max(1, bytes), 1); + for (int i = 0; i < vs.length; i++) { + if (vs[i]) { + long byteIdx = i >>> 3; + byte cur = seg.get(ValueLayout.JAVA_BYTE, byteIdx); + seg.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) (cur | (1 << (i & 7)))); + } + } + return new MaterializedBoolArray(new DType.Bool(false), vs.length, seg.asReadOnly()); + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/GridRenderTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/GridRenderTest.java new file mode 100644 index 00000000..0fd2644e --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/GridRenderTest.java @@ -0,0 +1,46 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.core.PType; +import io.github.dfa1.vortex.reader.array.LongArray; +import org.junit.jupiter.api.Test; + +import java.lang.foreign.Arena; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Unit tests for [GridRender.formatCell]: per-type rendering plus the empty-cell +/// guard paths (null array, out-of-range index), exercised against in-memory +/// arrays without a terminal or fixture file. +class GridRenderTest { + + private static final DType I64 = new DType.Primitive(PType.I64, false); + private static final DType F64 = new DType.Primitive(PType.F64, false); + private static final DType BOOL = new DType.Bool(false); + + @Test + void nullArrayRendersEmpty() { + assertThat(GridRender.formatCell(null, 0, I64)).isEmpty(); + } + + @Test + void outOfRangeIndexRendersEmpty() { + try (Arena arena = Arena.ofConfined()) { + LongArray a = ArrayFixtures.longs(arena, 1L, 2L); + assertThat(GridRender.formatCell(a, 5, I64)).isEmpty(); + } + } + + @Test + void rendersNumericAndBoolTypes() { + try (Arena arena = Arena.ofConfined()) { + assertThat(GridRender.formatCell(ArrayFixtures.longs(arena, 7L), 0, I64)).isEqualTo("7"); + assertThat(GridRender.formatCell(ArrayFixtures.ints(arena, -3), 0, I64)).isEqualTo("-3"); + assertThat(GridRender.formatCell(ArrayFixtures.shorts(arena, (short) 4), 0, I64)).isEqualTo("4"); + assertThat(GridRender.formatCell(ArrayFixtures.bytes(arena, (byte) 8), 0, I64)).isEqualTo("8"); + assertThat(GridRender.formatCell(ArrayFixtures.doubles(arena, 0.25), 0, F64)).isEqualTo("0.25"); + assertThat(GridRender.formatCell(ArrayFixtures.floats(arena, 1.25f), 0, F64)).isEqualTo("1.25"); + assertThat(GridRender.formatCell(ArrayFixtures.bools(arena, true), 0, BOOL)).isEqualTo("true"); + } + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/InspectorRenderTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/InspectorRenderTest.java new file mode 100644 index 00000000..daf35462 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/InspectorRenderTest.java @@ -0,0 +1,103 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.core.DType; +import io.github.dfa1.vortex.core.PType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.foreign.Arena; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Unit tests for [InspectorRender]: pure formatters exercised directly against +/// in-memory arrays — no terminal, worker, or encoded fixture required. +class InspectorRenderTest { + + private static final DType I64 = new DType.Primitive(PType.I64, false); + + @Nested + class FormatValue { + + @Test + void rendersEachNumericAndBoolType() { + try (Arena arena = Arena.ofConfined()) { + assertThat(InspectorRender.formatValue(ArrayFixtures.longs(arena, 42L), 0, I64)) + .isEqualTo("42"); + assertThat(InspectorRender.formatValue(ArrayFixtures.ints(arena, -7), 0, I64)) + .isEqualTo("-7"); + assertThat(InspectorRender.formatValue(ArrayFixtures.shorts(arena, (short) 5), 0, I64)) + .isEqualTo("5"); + assertThat(InspectorRender.formatValue(ArrayFixtures.bytes(arena, (byte) 9), 0, I64)) + .isEqualTo("9"); + assertThat(InspectorRender.formatValue(ArrayFixtures.doubles(arena, 1.5), 0, I64)) + .isEqualTo("1.5"); + assertThat(InspectorRender.formatValue(ArrayFixtures.floats(arena, 2.5f), 0, I64)) + .isEqualTo("2.5"); + assertThat(InspectorRender.formatValue(ArrayFixtures.bools(arena, true, false), 1, I64)) + .isEqualTo("false"); + } + } + } + + @Nested + class FormatHexRow { + + @Test + void rendersFullRowWithAsciiGutter() { + // 16 printable bytes 'A'..'P' -> hex columns + ASCII gutter + byte[] data = new byte[16]; + for (int i = 0; i < 16; i++) { + data[i] = (byte) ('A' + i); + } + + String row = InspectorRender.formatHexRow(data, 0); + + assertThat(row).startsWith("00000000 "); + assertThat(row).contains("41 42 43"); // A B C + assertThat(row).contains("|ABCDEFGHIJKLMNOP|"); + } + + @Test + void padsShortTrailingRowAndDotsNonPrintable() { + // 3 bytes incl a non-printable 0x00 -> '.' in the gutter, spaces for missing cols + byte[] data = {(byte) 'x', 0x00, (byte) 'y'}; + + String row = InspectorRender.formatHexRow(data, 0); + + assertThat(row).startsWith("00000000 "); + assertThat(row).contains("78 00 79"); + assertThat(row).contains("|x.y"); + } + } + + @Nested + class FormatBytes { + + @Test + void formatsAcrossUnitBoundaries() { + assertThat(InspectorRender.formatBytes(512)).isEqualTo("512 B"); + assertThat(InspectorRender.formatBytes(2048)).isEqualTo("2.0 KB"); + assertThat(InspectorRender.formatBytes(3 * 1024 * 1024)).isEqualTo("3.0 MB"); + } + } + + @Nested + class PadAndTruncate { + + @Test + void padExtendsToWidth() { + assertThat(InspectorRender.pad("ab", 5)).isEqualTo("ab ").hasSize(5); + } + + @Test + void padTruncatesWhenTooLong() { + assertThat(InspectorRender.pad("abcdef", 3)).isEqualTo("abc"); + } + + @Test + void truncateLeavesShortStringsUntouched() { + assertThat(InspectorRender.truncate("ab", 5)).isEqualTo("ab"); + assertThat(InspectorRender.truncate("abcdef", 3)).isEqualTo("abc"); + } + } +} From e795fe6d490a77b47792c0ac19b01daef3dcee89 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 18 Jun 2026 22:11:22 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(changelog):=20cut=200.8.0=20=E2=80=94?= =?UTF-8?q?=20variant=20encode/read=20+=20Materialized=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote [Unreleased] to [0.8.0] (2026-06-18): variant encode/decode (ADR 0014) under Added, and the lazy-only transform-decode sweep (ADR 0015) under Changed. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e00a1563..0ac2805a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] — 2026-06-18 + +Variant encode/decode and the Materialized-fallback sweep. + ### Added - Writer: `vortex.variant` encoder. Encodes a variant column as the canonical `vortex.variant` container over `core_storage` — an all-equal column becomes a single `vortex.constant`, a row-varying column a `vortex.chunked` of per-run constants — with an optional row-aligned typed `shredded` child recorded in `VariantMetadata.shredded_dtype`. Input is `VariantData(List)` with `.constant(n, v)` / `.shredded(...)` factories. Java↔Rust (JNI) round-trip verified for constant, row-varying, and shredded columns. Scalar values only — arbitrary nested objects need `vortex.parquet.variant` (deferred, [ADR 0014](docs/adr/0014-variant-encoding-strategy.md)). - Reader: variant columns now decode Java-side. `ConstantEncodingDecoder` and `ChunkedEncodingDecoder` handle `DType.Variant` (materialising the inner-typed array); `VariantEncodingDecoder` wraps the result as `VariantArray`, exposing `coreStorage()` and `shredded()`. +### Changed + +- Decode shape: transform encodings now decode **lazy-only**. The eager `Materialized*Array` fallbacks were removed from `vortex.zigzag` (all PTypes + broadcast), `fastlanes.for` (all integer PTypes), `vortex.alp` (broadcast-without-patches), `vortex.constant` (Decimal → `LazyConstantDecimalArray`), `vortex.runend` (Bool → `LazyRunEndBoolArray`), `vortex.sparse` (Bool → `LazySparseBoolArray`), and `fastlanes.rle` (validity → `OffsetBoolArray`, empty → `LazyConstantXxxArray`). Decompression encodings (`bitpacked`, `pco`, `zstd`, `fsst`, `delta`, `patched`), the primitive base, the `vortex.dict` encoding-level path, and the `vortex.alp` patches path stay Materialized by design. See [ADR 0015](docs/adr/0015-drop-materialized-fallbacks.md). + ## [0.7.3] — 2026-06-17 Parquet ZSTD support, `vortex.patched` encoder, constant-encoding selection fix, Windows TUI raw-mode fix.