XMLSubject.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.axiom.truth.xml;

import static com.google.common.truth.Truth.assertThat;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import javax.xml.namespace.QName;

import org.apache.axiom.truth.xml.spi.Event;
import org.apache.axiom.truth.xml.spi.Traverser;
import org.apache.axiom.truth.xml.spi.TraverserException;
import org.apache.axiom.truth.xml.spi.XML;

import com.google.common.truth.FailureStrategy;
import com.google.common.truth.Subject;

/**
 * Propositions for objects representing XML data.
 */
public final class XMLSubject extends Subject<XMLSubject,Object> {
    private final XML xml;
    private boolean ignoreComments;
    private boolean ignoreElementContentWhitespace;
    private boolean ignoreWhitespace;
    private boolean ignoreWhitespaceInPrologAndEpilog;
    private boolean ignorePrologAndEpilog;
    private boolean ignoreNamespaceDeclarations;
    private boolean ignoreNamespacePrefixes;
    private boolean ignoreRedundantNamespaceDeclarations;
    private boolean expandEntityReferences;
    private boolean treatWhitespaceAsText;
    
    XMLSubject(FailureStrategy failureStrategy, Object subject) {
        super(failureStrategy, subject);
        xml = XMLTruth.xml(subject);
    }

    /**
     * Ignore comments; same as {@code ignoringComments(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringComments() {
        return ignoringComments(true);
    }
    
    /**
     * Specifies if comments should be ignored.
     * 
     * @param value
     *            {@code true} if comments should be ignored, {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringComments(boolean value) {
        ignoreComments = value;
        return this;
    }

    /**
     * Ignore element content whitespace; same as {@code ignoringElementContentWhitespace(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringElementContentWhitespace() {
        return ignoringElementContentWhitespace(true);
    }
    
    /**
     * Specifies if element content whitespace should be ignored. Note
     * that this only has an effect for documents that have a DTD.
     * 
     * @param value
     *            {@code true} if element content whitespace should be ignored, {@code false}
     *            otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringElementContentWhitespace(boolean value) {
        ignoreElementContentWhitespace = true;
        return this;
    }
    
    /**
     * Ignore all whitespace; same as {@code ignoringWhitespace(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringWhitespace() {
        return ignoringWhitespace(true);
    }
    
    /**
     * Specifies if whitespace should be ignored.
     * 
     * @param value
     *            {@code true} if all text nodes that contain only whitespace should be ignored,
     *            {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringWhitespace(boolean value) {
        ignoreWhitespace = value;
        return this;
    }
    
    /**
     * Ignore whitespace in the prolog and epilog; same as
     * {@code ignoringWhitespaceInPrologAndEpilog(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringWhitespaceInPrologAndEpilog() {
        return ignoringWhitespaceInPrologAndEpilog(true);
    }
    
    /**
     * Specifies if whitespace in the prolog and epilog should be ignored. This is especially useful
     * when working with DOM documents because DOM strips whitespace from the prolog and epilog.
     * 
     * @param value
     *            {@code true} if whitespace in the prolog and epilog should be ignored,
     *            {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringWhitespaceInPrologAndEpilog(boolean value) {
        ignoreWhitespaceInPrologAndEpilog = value;
        return this;
    }
    
    /**
     * Ignore the prolog and epilog entirely; same as {@code ignoringPrologAndEpilog(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringPrologAndEpilog() {
        return ignoringPrologAndEpilog(true);
    }
    
    /**
     * Specifies if the prolog and epilog should be ignored entirely.
     * 
     * @param value
     *            {@code true} if (text, comment and document type declaration) nodes in the prolog
     *            and epilog should be ignored, {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringPrologAndEpilog(boolean value) {
        ignorePrologAndEpilog = value;
        return this;
    }
    
    /**
     * Ignore all namespace declarations; same as {@code ignoringNamespaceDeclarations(true)}.
     * 
     * @return <code>this</code>
     */
    public XMLSubject ignoringNamespaceDeclarations() {
        return ignoringNamespaceDeclarations(true);
    }
    
    /**
     * Specifies if namespace declarations should be ignored.
     * 
     * @param value
     *            {@code true} if namespace declarations should be ignored, {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringNamespaceDeclarations(boolean value) {
        ignoreNamespaceDeclarations = value;
        return this;
    }
    
    /**
     * Ignore namespace prefixes; same as {@code ignoringNamespacePrefixes(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringNamespacePrefixes() {
        return ignoringNamespacePrefixes(true);
    }
    
    /**
     * Specifies if namespace prefixes should be ignored.
     * 
     * @param value
     *            {@code true} if namespace prefixes are ignored when comparing elements and
     *            attributes, {@code false} otherwise
     * @return {@code this}
     */
    public XMLSubject ignoringNamespacePrefixes(boolean value) {
        ignoreNamespacePrefixes = value;
        return this;
    }
    
    /**
     * Ignore redundant namespace declarations; same as
     * {@code ignoringRedundantNamespaceDeclarations(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject ignoringRedundantNamespaceDeclarations() {
        return ignoringRedundantNamespaceDeclarations(true);
    }
    
    /**
     * Specify if redundant namespace declarations should be ignored. A namespace declaration is
     * considered redundant if its presence doesn't modify the namespace context.
     * 
     * @param value
     *            {@code true} if redundant namespace declarations should be ignored, {@code false}
     *            if all namespace declarations should be compared
     * @return {@code this}
     */
    public XMLSubject ignoringRedundantNamespaceDeclarations(boolean value) {
        ignoreRedundantNamespaceDeclarations = value;
        return this;
    }
    
    /**
     * Expand entity references; same as {@code expandingEntityReferences(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject expandingEntityReferences() {
        return expandingEntityReferences(true);
    }
    
    /**
     * Specifies if entity references should be expanded.
     * 
     * @param value
     *            {@code true} if entity references should be expanded and their replacement
     *            compared, {@code false} if the entity references themselves should be compared
     * @return {@code this}
     */
    public XMLSubject expandingEntityReferences(boolean value) {
        expandEntityReferences = value;
        return this;
    }
    
    /**
     * Treat element content whitespace as simple text nodes; same as
     * {@code treatingElementContentWhitespaceAsText(true)}.
     * 
     * @return {@code this}
     */
    public XMLSubject treatingElementContentWhitespaceAsText() {
        return treatingElementContentWhitespaceAsText(true);
    }
    
    /**
     * Specifies how element content whitespace is to be treated. Use this when comparing a document
     * that has a DTD with a document that doesn't.
     * 
     * @param value
     *            {@code true} if element whitespace should be considered as text nodes,
     *            {@code false} if element whitespace should be considered as a distinct node type
     * @return {@code this}
     */
    public XMLSubject treatingElementContentWhitespaceAsText(boolean value) {
        treatWhitespaceAsText = value;
        return this;
    }
    
    private Traverser createTraverser(XML xml) throws TraverserException {
        Traverser traverser = xml.createTraverser(expandEntityReferences);
        if (ignoreWhitespaceInPrologAndEpilog || ignorePrologAndEpilog) {
            final boolean onlyWhitespace = !ignorePrologAndEpilog;
            traverser = new Filter(traverser) {
                private int depth;

                @Override
                public Event next() throws TraverserException {
                    Event event;
                    while ((event = super.next()) != null) {
                        switch (event) {
                            case START_ELEMENT:
                                depth++;
                                break;
                            case END_ELEMENT:
                                depth--;
                                break;
                            default:
                                if (onlyWhitespace) {
                                    break;
                                }
                                // Fall through
                            case WHITESPACE:
                                if (depth == 0) {
                                    continue;
                                }
                        }
                        break;
                    }
                    return event;
                }
            };
        }
        final Set<Event> ignoredEvents = new HashSet<>();
        if (ignoreComments) {
            ignoredEvents.add(Event.COMMENT);
        }
        if (ignoreWhitespace || ignoreElementContentWhitespace) {
            ignoredEvents.add(Event.WHITESPACE);
        }
        if (!ignoredEvents.isEmpty()) {
            traverser = new Filter(traverser) {
                @Override
                public Event next() throws TraverserException {
                    Event event;
                    while (ignoredEvents.contains(event = super.next())) {
                        // loop
                    }
                    return event;
                }
            };
        }
        traverser = new CoalescingFilter(traverser);
        if (ignoreWhitespace) {
            traverser = new Filter(traverser) {
                @Override
                public Event next() throws TraverserException {
                    Event event = super.next();
                    if (event == Event.TEXT) {
                        String text = getText();
                        for (int i=0; i<text.length(); i++) {
                            if (" \r\n\t".indexOf(text.charAt(i)) == -1) {
                                return Event.TEXT;
                            }
                        }
                        return super.next();
                    } else {
                        return event;
                    }
                }
            };
        }
        if (ignoreRedundantNamespaceDeclarations && !ignoreNamespaceDeclarations) {
            traverser = new RedundantNamespaceDeclarationFilter(traverser);
        }
        return traverser;
    }
    
    private static Map<QName,String> extractPrefixes(Set<QName> qnames) {
        Map<QName,String> result = new HashMap<>();
        for (QName qname : qnames) {
            result.put(qname, qname.getPrefix());
        }
        return result;
    }
    
    /**
     * Fails unless the subject represents the same XML as the given object.
     * 
     * @param other
     *            the object to compare with
     */
    public void hasSameContentAs(Object other) {
        try {
            Traverser actual = createTraverser(xml);
            XML expectedXML = XMLTruth.xml(other);
            Traverser expected = createTraverser(expectedXML);
            while (true) {
                Event actualEvent = actual.next();
                Event expectedEvent = expected.next();
                if (expectedEvent == Event.WHITESPACE || expectedEvent == Event.TEXT) {
                    if (!xml.isReportingElementContentWhitespace()) {
                        assertThat(actualEvent).isEqualTo(Event.TEXT);
                    } else if (treatWhitespaceAsText || !expectedXML.isReportingElementContentWhitespace()) {
                        assertThat(actualEvent).isAnyOf(Event.WHITESPACE, Event.TEXT);
                    } else {
                        assertThat(actualEvent).isEqualTo(expectedEvent);
                    }
                } else {
                    assertThat(actualEvent).isEqualTo(expectedEvent);
                }
                if (expectedEvent == null) {
                    break;
                }
                switch (expectedEvent) {
                    case DOCUMENT_TYPE:
                        assertThat(actual.getRootName()).isEqualTo(expected.getRootName());
                        assertThat(actual.getPublicId()).isEqualTo(expected.getPublicId());
                        assertThat(actual.getSystemId()).isEqualTo(expected.getSystemId());
                        break;
                    case START_ELEMENT:
                        QName actualQName = actual.getQName();
                        Map<QName,String> actualAttributes = actual.getAttributes();
                        QName expectedQName = expected.getQName();
                        Map<QName,String> expectedAttributes = expected.getAttributes();
                        assertThat(actualQName).isEqualTo(expectedQName);
                        assertThat(actualAttributes).isEqualTo(expectedAttributes);
                        if (!ignoreNamespacePrefixes) {
                            assertThat(actualQName.getPrefix()).isEqualTo(expectedQName.getPrefix());
                            if (expectedAttributes != null) {
                                assertThat(extractPrefixes(actualAttributes.keySet())).isEqualTo(extractPrefixes(expectedAttributes.keySet()));
                            }
                        }
                        if (!ignoreNamespaceDeclarations) {
                            assertThat(actual.getNamespaces()).isEqualTo(expected.getNamespaces());
                        }
                        break;
                    case END_ELEMENT:
                        break;
                    case TEXT:
                    case WHITESPACE:
                    case COMMENT:
                    case CDATA_SECTION:
                        assertThat(actual.getText()).isEqualTo(expected.getText());
                        break;
                    case ENTITY_REFERENCE:
                        if (expandEntityReferences) {
                            throw new IllegalStateException();
                        }
                        assertThat(actual.getEntityName()).isEqualTo(expected.getEntityName());
                        break;
                    case PROCESSING_INSTRUCTION:
                        assertThat(actual.getPITarget()).isEqualTo(expected.getPITarget());
                        assertThat(actual.getPIData()).isEqualTo(expected.getPIData());
                        break;
                    default:
                        throw new IllegalStateException();
                }
            }
        } catch (TraverserException ex) {
            // TODO: check how to fail properly
            throw new RuntimeException(ex);
        }
    }
}