AsciiDocToText.java

package pro.verron.asciidoc.converters;

import pro.verron.asciidoc.core.*;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

/// Converts an [AsciiDocModel] to a plain AsciiDoc text string.
///
/// Implements [Function]<[AsciiDocModel], String> and renders headings,
/// paragraphs, lists, tables, blockquotes, code blocks, images, inline
/// elements (bold, italic, superscript, subscript, styled, links), and macros
/// into their AsciiDoc text representation.
///
/// @see AsciiDocToHtml
/// @see AsciiDocToSvg
public final class AsciiDocToText
        implements Function<AsciiDocModel, String> {

    private final boolean skipComments;

    /// Constructs a converter that optionally skips comment blocks.
    ///
    /// @param skipComments whether to omit comment blocks in the output
    public AsciiDocToText(boolean skipComments) {
        this.skipComments = skipComments;
    }

    private static String renderInlines(List<Inline> inlines) {
        var sb = new StringBuilder();
        for (var inline : inlines) {
            sb.append(switch (inline) {
                case Text(String text) -> text;
                case Bold(List<Inline> children) ->
                        "*%s*".formatted(renderInlines(children));
                case Italic(List<Inline> children) ->
                        "_%s_".formatted(renderInlines(children));
                case Sup(List<Inline> children) ->
                        "^%s^".formatted(renderInlines(children));
                case Sub(List<Inline> children) ->
                        "~%s~".formatted(renderInlines(children));
                case Tab _ -> "\t";
                case Link(String url, String text) ->
                        "%s[%s]".formatted(url, text);
                case ImageInline(String path, Map<String, String> map) ->
                        "image:%s[%s]".formatted(path,
                                map.entrySet()
                                   .stream()
                                   .map(e -> e.getKey() + "=" + e.getValue())
                                   .collect(Collectors.joining(", ")));
                case Styled(String role, List<Inline> children) ->
                        "[%s]#%s#".formatted(role, renderInlines(children));
                case MacroInline(String name, String id, List<String> list) ->
                        "%s:%s[%s]".formatted(name,
                                id,
                                String.join(", ", list));
            });
        }
        return sb.toString();
    }

    private static String renderImageBlock(String url, String altText) {
        return "image::%s[%s]".formatted(url, altText);
    }

    private static String renderCodeBlock(String language, String content) {
        return language.isEmpty() ? """
                ----
                %s
                ----""".formatted(content) : """
                [source,%s]
                ----
                %s
                ----""".formatted(language, content);
    }

    private static String renderBlockquote(List<Inline> inlines) {
        return """
                ____
                %s
                ____""".formatted(renderInlines(inlines));
    }

    private static String renderList(List<ListItem> items, String x) {
        return items.stream()
                    .map(item -> x + renderInlines(item.inlines()))
                    .collect(Collectors.joining("\n"));
    }

    private static String renderHeading(int level, List<Inline> inlines) {
        return "%s %s".formatted("=".repeat(level), renderInlines(inlines));
    }

    private static String renderHeader(List<String> header) {
        return header.isEmpty()
                ? ""
                : "[%s]\n".formatted(String.join(", ", header));
    }

    private String renderCellContent(Cell cell, boolean isAsciidoc, int level) {
        var blockList = cell.blocks();
        if (!isAsciidoc) {
            if (blockList.isEmpty()) return "";
            var p = (Paragraph) blockList.getFirst();
            return renderInlines(p.inlines());
        }
        else return blockList.stream()
                             .map(block -> renderBlock(block, level))
                             .collect(Collectors.joining())
                             .trim();
    }

    private String renderBlock(Block block, int tableLevel) {
        var string = switch (block) {
            case Heading(_, int level, List<Inline> inlines) ->
                    renderHeading(level, inlines);
            case Paragraph(List<String> header, List<Inline> inlines) ->
                    renderHeader(header) + renderInlines(inlines);
            case UnorderedList(List<ListItem> items1) ->
                    renderList(items1, "* ");
            case OrderedList(List<ListItem> items) -> renderList(items, ". ");
            case Table(List<Row> rows) -> renderTable(rows, tableLevel);
            case QuoteBlock(List<Inline> inlines) -> renderBlockquote(inlines);
            case CodeBlock(String language, String content) ->
                    renderCodeBlock(language, content);
            case ImageBlock(String url, String altText) ->
                    renderImageBlock(url, altText);
            case OpenBlock openBlock -> render(openBlock);
            case MacroBlock(List<String> list, String name, String id) ->
                    "%s::%s[%s]".formatted(name, id, String.join(", ", list));
            case Break _ -> "<<<";
            case CommentBlock(String comment) ->
                    skipComments ? null : ("// %s").formatted(comment);
        };
        return string == null ? "" : string + "\n\n";
    }

    private String render(OpenBlock openBlock) {
        var sb = new StringBuilder();
        sb.append("[%s]\n".formatted(String.join(", ", openBlock.header())));
        sb.append("--\n");
        openBlock.content()
                 .stream()
                 .map(p -> renderBlock(p, 0))
                 .forEach(sb::append);
        sb.append("--\n");
        return sb.toString();
    }

    private String renderTable(List<Row> rows, int level) {
        var cellDelimiter = switch (level) {
            case 0 -> "|";
            case 1 -> "!";
            default -> throw new IllegalArgumentException(
                    "Table nesting level must be between 0 and 1");
        };
        var tableDelimiter = cellDelimiter + "===";
        var sb = new StringBuilder();
        sb.append(tableDelimiter);
        sb.append("\n");
        for (var row : rows) {
            var header = String.join(",", row.header());
            if (!header.isEmpty()) sb.append('[')
                                     .append(header)
                                     .append("]\n");
            for (var cell : row.cells()) {
                var blockList = cell.blocks();
                var size = blockList.size();
                boolean isAsciidoc = size > 1 || (size == 1
                                                  && !(blockList.getFirst() instanceof Paragraph));
                cell.style()
                    .ifPresent(s -> sb.append("[%s]\n".formatted(s)));
                sb.append(isAsciidoc ? "a" + cellDelimiter : cellDelimiter)
                  .append(renderCellContent(cell, isAsciidoc, level + 1))
                  .append("\n");
            }
        }
        sb.append(tableDelimiter);
        return sb.toString();
    }

    /// Applies the conversion on the given AsciiDoc model and renders its
    /// blocks into a concatenated string representation.
    ///
    /// @param model the parsed AsciiDoc model
    ///
    /// @return the rendered AsciiDoc text
    public String apply(AsciiDocModel model) {
        return model.getBlocks()
                    .stream()
                    .map((Block block) -> renderBlock(block, 0))
                    .collect(Collectors.joining());
    }
}