Profiler.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 2015 ForgeRock AS.
 */

package org.forgerock.doc.maven.utils;

import org.forgerock.doc.maven.utils.helper.FileFilterFactory;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.util.Map;

/**
 * Offers an alternative to <a href="http://www.sagehill.net/docbookxsl/Profiling.html">DocBook profiling</a>.
 * <br>
 * docbkx-tools relies on support in the DocBook XSL distribution,
 * which does not appear to include profiling support for webhelp output (v1.78.1).
 * docbkx-tools also handles the profiling in the same pass as the build,
 * meaning that dangling links might result in the built output.
 * <br>
 * This implementation can be used to handle profiling (conditional text) during pre-processing.
 * Build tools and validators then need not deal with DocBook profiles.
 */
public class Profiler {

    /** Profile maps: keys == attr names; values == attr values. **/
    private final Map<String, String> inclusions;
    private final Map<String, String> exclusions;

    /**
     * Constructs a profiler based on a profile inclusions configuration.
     * <br>
     * The profile maps do not restrict the keys (attribute names) to those supported by DocBook profiling.
     *
     * @param inclusions    Profile map: keys == attr names; values == attr values.
     *                      Lists of attr values must be space-separated.
     *                      If no inclusions are specified, set this to null.
     * @param exclusions    Profile map: keys == attr names; values == attr values.
     *                      Lists of attr values must be space-separated.
     *                      If no exclusions are specified, set this to null.
     */
    public Profiler(final Map<String, String> inclusions, final Map<String, String> exclusions) {
        this.inclusions = inclusions;
        this.exclusions = exclusions;
    }

    /**
     * Applies inclusions and exclusions if any to the XML files under the source directory.
     * <br>
     * This method changes the files in place, so the source should be a modifiable copy.
     *
     * @param xmlSourceDirectory    Source directory for XML files to profile.
     * @throws IOException          Failed to transform an XML file.
     */
    public void applyProfiles(final File xmlSourceDirectory) throws IOException {
        if (inclusions == null && exclusions == null) { // Nothing to do.
            return;
        }

        final FileFilter fileFilter = FileFilterFactory.getXmlFileFilter();
        Transformer transformer = new Transformer(getXsl(), fileFilter);
        transformer.update(xmlSourceDirectory);
    }

    /**
     * Returns an XSLT stylesheet suitable for profiling.
     * <br>
     * If no inclusions or exclusions are set, this makes a copy (identity transform).
     *
     * @return An XSLT stylesheet suitable for profiling.
     */
    private String getXsl() {
        final StringBuilder sb = new StringBuilder();
        sb.append("<?xml version=\"1.0\"?>\n")
                .append("<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n")
                .append("  <xsl:template match=\"node()|@*\">\n")
                .append("    <xsl:copy>\n")
                .append("      <xsl:apply-templates select=\"node()|@*\"/>\n")
                .append("    </xsl:copy>\n")
                .append("  </xsl:template>\n");

        if (inclusions != null) {
            for (final String attribute : inclusions.keySet()) {
                sb.append("<xsl:template match=\"//*[")
                        .append(getInclusionsMatch(attribute, inclusions.get(attribute).split("\\s+")))
                        .append("]\"/>\n");
            }
        }

        if (exclusions != null) {
            for (final String attribute : exclusions.keySet()) {
                sb.append("<xsl:template match=\"//*[")
                        .append(getExclusionsMatch(attribute, exclusions.get(attribute).split("\\s+")))
                        .append("]\"/>\n");
            }
        }

        sb.append("</xsl:stylesheet>\n");
        return sb.toString();
    }

    /**
     * Returns a partial XPath expression to match inclusions.
     * <br>
     * This follows a sort of reversed logic to <em>exclude non-matching elements</em>:
     * <br>
     * attr "os", values { "linux", "unix" } results in "@os != 'linux' and @os != 'unix'"
     * <br>
     * attr "condition", values { "release" } results in "@condition != 'release'"
     *
     * @param attribute The attribute for which to include elements with matching values.
     * @param values    The attribute values to match for inclusion.
     * @return          A partial XPath expression to match inclusions.
     */
    private String getInclusionsMatch(final String attribute, final String[] values) {
        if (attribute == null || attribute.isEmpty()) {
            return "";
        }

        if (values == null || values.length == 0) {
            return "";
        }

        if (values.length == 1) {
            return "@" + attribute + " != '" + values[0] + "'";
        } else {
            StringBuilder sb = new StringBuilder();
            for (int i = values.length - 1; i > 0; i--) {
                sb.append("@").append(attribute).append(" != '").append(values[i]).append("' and ");
            }
            sb.append("@").append(attribute).append(" != '").append(values[0]).append("'");
            return sb.toString();
        }
    }

    /**
     * Returns a partial XPath expression to match exclusions.
     * <br>
     * attr "os", values { "linux", "unix" } results in "@os = 'linux' or @os = 'unix'"
     * <br>
     * attr "condition", values { "draft" } results in "@condition = 'draft'"
     *
     * @param attribute The attribute for which to exclude elements with matching values.
     * @param values    The attribute values to match for exclusion.
     * @return          A partial XPath expression to match exclusions.
     */
    private String getExclusionsMatch(final String attribute, final String[] values) {
        if (attribute == null || attribute.isEmpty()) {
            return "";
        }

        if (values == null || values.length == 0) {
            return "";
        }

        if (values.length == 1) {
            return "@" + attribute + " = '" + values[0] + "'";
        } else {
            StringBuilder sb = new StringBuilder();
            for (int i = values.length - 1; i > 0; i--) {
                sb.append("@").append(attribute).append(" = '").append(values[i]).append("' or ");
            }
            sb.append("@").append(attribute).append(" = '").append(values[0]).append("'");
            return sb.toString();
        }
    }

    /**
     * Applies an XSL transformation to the matching files.
     */
    private class Transformer extends XmlTransformer {
        /**
         * Constructs an updater to match DocBook XML files.
         * <br>
         * The files are updated in place.
         *
         * @param xsl           XSL as a String.
         * @param filterToMatch Filter to match XML files.
         */
        public Transformer(String xsl, FileFilter filterToMatch) {
            super(xsl, filterToMatch);
        }
    }
}