package org.dcache.util; import com.google.common.base.Strings; import com.google.common.primitives.Ints; import java.io.PrintWriter; import java.io.StringWriter; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import static org.dcache.util.ByteUnit.BYTES; import static org.dcache.util.ByteUnit.Type.DECIMAL; import static org.dcache.util.ByteUnits.isoSymbol; /** * Utility class to output a formatted table. * * Automatically determines column width, supports various data types, left and * right adjusted column, and column headers. */ public class ColumnWriter { private final List<String> headers = new ArrayList<>(); private final List<Integer> spaces = new ArrayList<>(); private final List<Column> columns = new ArrayList<>(); private final List<Row> rows = new ArrayList<>(); private boolean abbrev; public enum DateStyle { /** ISO 8601. */ ISO, /** As with GNU coreutils 'ls -l'. */ LS } public ColumnWriter() { spaces.add(0); } private void addColumn(Column column) { columns.add(column); if (headers.size() < columns.size()) { headers.add(null); } spaces.add(0); } public ColumnWriter left(String name) { addColumn(new LeftColumn(name)); return this; } public ColumnWriter right(String name) { addColumn(new RightColumn(name)); return this; } public ColumnWriter bytes(String name) { addColumn(new ByteColumn(name)); return this; } public ColumnWriter fixed(String value) { addColumn(new FixedColumn(value)); return this; } public ColumnWriter space() { int last = spaces.size() - 1; spaces.set(last, spaces.get(last) + 1); return this; } public ColumnWriter abbreviateBytes(boolean abbrev) { this.abbrev = abbrev; return this; } public TabulatedRow row() { TabulatedRow row = new TabulatedRow(); rows.add(row); return row; } public void row(String value) { rows.add(new LiteralRow(value)); } @Override public String toString() { if (rows.isEmpty()) { return ""; } List<Integer> widths = calculateWidths(); List<Integer> spaces = new ArrayList<>(this.spaces); StringWriter result = new StringWriter(); try (PrintWriter out = new NoTrailingWhitespacePrintWriter(result)) { String header = renderHeader(spaces, widths); if (!header.isEmpty()) { out.println(header); } for (Row row: rows) { row.render(columns, spaces, widths, out); } } return result.toString(); } private List<Integer> calculateWidths() { int columnCount = columns.size(); int[] widths = new int[columnCount]; for (Row row : rows) { for (int i = 0; i < columnCount; i++) { widths[i] = Math.max(widths[i], row.width(columns.get(i))); } } return Ints.asList(widths); } private String renderHeader(List<Integer> spaces, List<Integer> widths) { int columnCount = columns.size(); StringBuilder line = new StringBuilder(); int columnEnd = 0; for (int i = 0; i < columnCount; i++) { String header = headers.get(i); if (header != null) { int headerStart; if (columns.get(i).isLeftJustified()) { headerStart = columnEnd + spaces.get(i); } else { headerStart = columnEnd + spaces.get(i) + widths.get(i) - header.length(); } if (line.length() >= headerStart) { int newHeaderStart = (line.length() > 0) ? line.length() + 1 : 0; spaces.set(i, spaces.get(i) + newHeaderStart - headerStart); headerStart = newHeaderStart; } for (int c = line.length(); c < headerStart; c++) { line.append(' '); } line.append(header); } columnEnd = columnEnd + spaces.get(i) + widths.get(i); } return line.toString(); } public ColumnWriter date(String name) { addColumn(new DateColumn(name)); return this; } public ColumnWriter date(String name, DateStyle style) { addColumn(new DateColumn(name, style)); return this; } public ColumnWriter header(String text) { headers.add(text); return this; } private interface Column { String name(); boolean isLeftJustified(); int width(Object value); void render(Object value, int actualWidth, PrintWriter writer); } private abstract static class AbstractColumn implements Column { protected final String name; public AbstractColumn(String name) { this.name = name; } @Override public String name() { return name; } } private abstract static class RegularColumn extends AbstractColumn { private RegularColumn(String name) { super(name); } @Override public int width(Object value) { return Objects.toString(value, "").length(); } } private static class LeftColumn extends RegularColumn { private LeftColumn(String name) { super(name); } @Override public boolean isLeftJustified() { return true; } @Override public void render(Object value, int actualWidth, PrintWriter out) { out.append(Strings.padEnd(Objects.toString(value, ""), actualWidth, ' ')); } } private static class RightColumn extends RegularColumn { private RightColumn(String name) { super(name); } @Override public boolean isLeftJustified() { return false; } @Override public void render(Object value, int actualWidth, PrintWriter out) { out.append(Strings.padStart(Objects.toString(value, ""), actualWidth, ' ')); } } /** * Right adjusted column for byte quantities. Supports a narrow column mode in * which quantities are rounded to fit in three characters plus a trailing * SI prefix. */ private class ByteColumn extends AbstractColumn { public ByteColumn(String name) { super(name); } @Override public boolean isLeftJustified() { return false; } @Override public int width(Object value) { if (abbrev && value != null) { return DECIMAL.unitsOf((long) value) == BYTES ? 4 : 5; } else { return Objects.toString(value, "").length(); } } private String render(long value, ByteUnit units) { if (units == BYTES) { return String.format("%3d", value); } else { double tmp = units.convert((double) value, BYTES); if (tmp >= 0 && tmp < 9.95) { return String.format("%.1f", tmp); } else { return String.format("%.0f", tmp); } } } private void render(long value, int actualWidth, PrintWriter out) { ByteUnit units = DECIMAL.unitsOf(value); String symbol = isoSymbol().of(units); String numerical = render(value, units); int padding = actualWidth - numerical.length() - symbol.length(); while (padding-- > 0) { out.append(' '); } out.append(numerical).append(symbol); } @Override public void render(Object o, int actualWidth, PrintWriter out) { if (o == null) { while (actualWidth-- > 0) { out.append(' '); } } else { long value = (long) o; if (abbrev) { render(value, actualWidth, out); } else { out.format("%" + actualWidth + 'd', value); } } } } private static class FixedColumn implements Column { private final String value; public FixedColumn(String value) { this.value = value; } @Override public boolean isLeftJustified() { return true; } @Override public String name() { return null; } @Override public int width(Object o) { return value.length(); } @Override public void render(Object o, int actualWidth, PrintWriter out) { out.append(Strings.padEnd(value, actualWidth, ' ')); } } private static class DateColumn extends AbstractColumn { public static final String ISO_FORMAT = "%1$tF %1$tT"; public static final String LS_YEAR_FORMAT = "%1$tb %1$2te %1$tY"; public static final String LS_NO_YEAR_FORMAT = "%1$tb %1$2te %1$tR"; public static final int WIDTH_OF_ISO_FORMAT = 19; public static final int WIDTH_OF_LS_FORMAT = 12; public final DateStyle style; private final long sixMonthsInPast; private final long oneHourInFuture; public DateColumn(String name) { this(name, DateStyle.ISO); } public DateColumn(String name, DateStyle style) { super(name); this.style = style; oneHourInFuture = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.MONTH, -6); sixMonthsInPast = calendar.getTimeInMillis(); } @Override public boolean isLeftJustified() { return false; } @Override public int width(Object value) { switch (style) { case ISO: return WIDTH_OF_ISO_FORMAT; case LS: return WIDTH_OF_LS_FORMAT; default: throw new RuntimeException("Unknown style: " + style); } } private String getFormat(Object value) { switch (style) { case ISO: return ISO_FORMAT; case LS: Date when = (Date) value; if (when.getTime() < sixMonthsInPast || when.getTime() > oneHourInFuture) { return LS_YEAR_FORMAT; } else { return LS_NO_YEAR_FORMAT; } default: throw new RuntimeException("Unknown style: " + style); } } @Override public void render(Object value, int actualWidth, PrintWriter out) { if (value == null) { while (actualWidth-- > 0) { out.append(' '); } } else { out.format(getFormat(value), value); } } } private interface Row { int width(Column column); void render(List<Column> columns, List<Integer> spaces, List<Integer> widths, PrintWriter out); } public static class TabulatedRow implements Row { private final Map<String, Object> values = new HashMap<>(); public TabulatedRow value(String column, Object value) { values.put(column, value); return this; } @Override public int width(Column column) { return column.width(values.get(column.name())); } @Override public void render(List<Column> columns, List<Integer> spaces, List<Integer> widths, PrintWriter out) { int size = columns.size(); for (int i = 0; i < size; i++) { for (int c = spaces.get(i); c > 0; c--) { out.append(' '); } Column column = columns.get(i); Object value = values.get(column.name()); column.render(value, widths.get(i), out); } out.println(); } } private static class LiteralRow implements Row { private final String value; private LiteralRow(String value) { this.value = value; } @Override public int width(Column column) { return 0; } @Override public void render(List<Column> columns, List<Integer> spaces, List<Integer> widths, PrintWriter out) { out.println(value); } } }