AsciiDocTable.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */

package org.forgerock.api.markup.asciidoc;

import static org.forgerock.api.markup.asciidoc.AsciiDocSymbols.*;
import static org.forgerock.api.util.ValidationUtil.isEmpty;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.forgerock.util.Reject;

/**
 * AsciiDoc table builder [<a href="http://asciidoctor.org/docs/user-manual/#tables">ref</a>], which defers insertion
 * of the table, at the end of the parent document, until {@link #tableEnd()} is called.
 * <p>
 * This class is not thread-safe.
 * </p>
 */
public class AsciiDocTable {

    /**
     * <em>Small</em> column-width for use with {@link #columnWidths(int...)}.
     */
    public static final int COLUMN_WIDTH_SMALL = 1;

    /**
     * <em>Medium</em> column-width (2x {@link #COLUMN_WIDTH_SMALL}) for use with {@link #columnWidths(int...)}.
     */
    public static final int COLUMN_WIDTH_MEDIUM = 2;

    private static final Pattern TABLE_CELL_SYMBOL_PATTERN = Pattern.compile("\\|");

    private final AsciiDoc asciiDoc;
    private final StringBuilder builder;
    private final List<String> cells;
    private int[] columnWidths;
    private Integer columnsPerRow;
    private String title;
    private boolean hasHeader;

    AsciiDocTable(final AsciiDoc asciiDoc, final StringBuilder builder) {
        this.asciiDoc = Reject.checkNotNull(asciiDoc);
        this.builder = Reject.checkNotNull(builder);
        cells = new ArrayList<>();
    }

    /**
     * Sets a table-title.
     *
     * @param title Table-title
     * @return Table builder
     */
    public AsciiDocTable title(final String title) {
        if (isEmpty(title)) {
            throw new AsciiDocException("title required");
        }
        if (this.title != null) {
            throw new AsciiDocException("title already defined");
        }
        this.title = title;
        return this;
    }

    /**
     * Sets the column headers, where blank entries can be null/empty, but the length of the headers array must
     * be equal to the number of columns in the table.
     *
     * @param columnHeaders Column headers
     * @return Table builder
     */
    public AsciiDocTable headers(final List<String> columnHeaders) {
        return headers(columnHeaders.toArray(new String[columnHeaders.size()]));
    }

    /**
     * Sets the column headers, where blank entries can be null/empty, but the length of the headers array must
     * be equal to the number of columns in the table.
     *
     * @param columnHeaders Column headers
     * @return Table builder
     */
    public AsciiDocTable headers(final String... columnHeaders) {
        if (isEmpty(columnHeaders)) {
            throw new AsciiDocException("columnHeaders required");
        }
        if (hasHeader) {
            throw new AsciiDocException("headers already defined");
        }
        if (columnsPerRow == null) {
            columnsPerRow = columnHeaders.length;
        } else if (columnsPerRow != columnHeaders.length) {
            throw new AsciiDocException("columnHeaders.length != columnsPerRow");
        }
        hasHeader = true;

        // add to front of cells
        cells.add(null);
        for (int i = columnHeaders.length - 1; i > -1; --i) {
            cells.add(0, TABLE_CELL + normalizeColumnCell(columnHeaders[i]));
        }
        return this;
    }

    /**
     * Sets number of columns per row, which is implicitly set by {@link #headers(String...)} and
     * {@link #columnWidths(int...)}.
     * <p>
     * This value can only be set once.
     * </p>
     *
     * @param columnsPerRow Columns per row
     * @return Table builder
     */
    public AsciiDocTable columnsPerRow(final int columnsPerRow) {
        if (this.columnsPerRow != null) {
            throw new AsciiDocException("columnsPerRow already defined");
        }
        if (this.columnsPerRow < 1) {
            throw new AsciiDocException("columnsPerRow < 1");
        }
        this.columnsPerRow = columnsPerRow;
        return this;
    }

    /**
     * Sets the widths for all columns-per-row, which can be a proportional integer (the default is 1) or a
     * percentage (1 to 99).
     *
     * @param columnWidths An entry for each column-per row in value range [1,99]
     * @return Table builder
     */
    public AsciiDocTable columnWidths(final List<Integer> columnWidths) {
        final int[] array = new int[columnWidths.size()];
        for (int i = 0; i < array.length; ++i) {
            array[i] = columnWidths.get(i);
        }
        return columnWidths(array);
    }

    /**
     * Sets the widths for all columns-per-row, which can be a proportional integer (the default is 1) or a
     * percentage (1 to 99).
     *
     * @param columnWidths An entry for each column-per row in value range [1,99]
     * @return Table builder
     */
    public AsciiDocTable columnWidths(final int... columnWidths) {
        if (columnWidths == null || columnWidths.length == 0) {
            throw new AsciiDocException("columnWidths required");
        }
        for (final int w : columnWidths) {
            if (w < 1 || w > 99) {
                throw new AsciiDocException("columnWidths values must be within range [1,99]");
            }
        }
        if (columnsPerRow != null) {
            if (columnsPerRow != columnWidths.length) {
                throw new AsciiDocException("columnWidths.length != columnsPerRow");
            }
        } else {
            columnsPerRow = columnWidths.length;
        }
        this.columnWidths = columnWidths;
        return this;
    }

    /**
     * Inserts a column-cell.
     *
     * @param columnCell Column-cell or {@code null} for empty cell
     * @return Table builder
     */
    public AsciiDocTable columnCell(final String columnCell) {
        cells.add(TABLE_CELL + normalizeColumnCell(columnCell));
        return this;
    }

    /**
     * Inserts a column-cell, with a style.
     *
     * @param columnCell Column-cell or {@code null} for empty cell
     * @param style Column-style
     * @return Table builder
     */
    public AsciiDocTable columnCell(final String columnCell, final AsciiDocTableColumnStyles style) {
        cells.add(style.toString() + TABLE_CELL + normalizeColumnCell(columnCell));
        return this;
    }

    /**
     * Adds an optional space to visually delineate the end of a row in the generated markup. The intention is that
     * this method would be called after adding all columns for a given row.
     *
     * @return table builder
     */
    public AsciiDocTable rowEnd() {
        cells.add(null);
        return this;
    }

    private String normalizeColumnCell(final String columnCell) {
        if (isEmpty(columnCell)) {
            // allow for empty cells
            return "";
        }
        // escape TABLE_CELL symbols
        final Matcher m = TABLE_CELL_SYMBOL_PATTERN.matcher(columnCell);
        return m.find() ? m.replaceAll("\\" + TABLE_CELL) : columnCell;
    }

    /**
     * Completes the table being built, and inserts it at the end of the parent document.
     *
     * @return Doc builder
     */
    public AsciiDoc tableEnd() {
        if (columnsPerRow == null) {
            throw new AsciiDocException("columnsPerRow has not be defined");
        }

        // table configuration (e.g., [cols="2*", caption="", options="header"])
        builder.append("[cols=\"");
        if (columnWidths != null) {
            // unique column widths
            builder.append(columnWidths[0]);
            for (int i = 1; i < columnWidths.length; ++i) {
                builder.append(',').append(columnWidths[i]);
            }
        } else {
            // each column same width
            builder.append(columnsPerRow).append("*");
        }
        builder.append("\", caption=\"\", options=\"");
        if (hasHeader) {
            builder.append("header");
        }
        builder.append("\"]").append(NEWLINE);

        // optional title
        if (title != null) {
            builder.append(".").append(title).append(NEWLINE);
        }

        // cells
        builder.append(TABLE).append(NEWLINE);
        if (cells.get(cells.size() - 1) == null) {
            // remove trailing "row spacer"
            cells.remove(cells.size() - 1);
        }
        for (final String item : cells) {
            if (item != null) {
                // null is an optional "row spacer" (see endRow), otherwise cells will be non-null
                builder.append(item);
            }
            builder.append(NEWLINE);
        }
        builder.append(TABLE).append(NEWLINE);

        return asciiDoc;
    }

}