XmlEventUtil.java

/*
 * SPDX-FileCopyrightText: none
 * SPDX-License-Identifier: CC0-1.0
 */

package gov.nist.secauto.metaschema.core.model.util;

import gov.nist.secauto.metaschema.core.util.ObjectUtils;

import org.codehaus.stax2.XMLEventReader2;
import org.codehaus.stax2.XMLStreamReader2;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import javax.xml.namespace.QName;
import javax.xml.stream.Location;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.events.Characters;
import javax.xml.stream.events.EndElement;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;

public final class XmlEventUtil { // NOPMD this is a set of utility methods
  private static final Pattern WHITESPACE_ONLY = Pattern.compile("^\\s+$");

  private static final Map<Integer, String> EVENT_NAME_MAP = new HashMap<>(); // NOPMD - this value is immutable

  static {
    EVENT_NAME_MAP.put(XMLStreamConstants.START_ELEMENT, "START_ELEMENT");
    EVENT_NAME_MAP.put(XMLStreamConstants.END_ELEMENT, "END_ELEMENT");
    EVENT_NAME_MAP.put(XMLStreamConstants.PROCESSING_INSTRUCTION, "PROCESSING_INSTRUCTION");
    EVENT_NAME_MAP.put(XMLStreamConstants.CHARACTERS, "CHARACTERS");
    EVENT_NAME_MAP.put(XMLStreamConstants.COMMENT, "COMMENT");
    EVENT_NAME_MAP.put(XMLStreamConstants.SPACE, "SPACE");
    EVENT_NAME_MAP.put(XMLStreamConstants.START_DOCUMENT, "START_DOCUMENT");
    EVENT_NAME_MAP.put(XMLStreamConstants.END_DOCUMENT, "END_DOCUMENT");
    EVENT_NAME_MAP.put(XMLStreamConstants.ENTITY_REFERENCE, "ENTITY_REFERENCE");
    EVENT_NAME_MAP.put(XMLStreamConstants.ATTRIBUTE, "ATTRIBUTE");
    EVENT_NAME_MAP.put(XMLStreamConstants.DTD, "DTD");
    EVENT_NAME_MAP.put(XMLStreamConstants.CDATA, "CDATA");
    EVENT_NAME_MAP.put(XMLStreamConstants.NAMESPACE, "XML_NAMESPACE");
    EVENT_NAME_MAP.put(XMLStreamConstants.NOTATION_DECLARATION, "NOTATION_DECLARATION");
    EVENT_NAME_MAP.put(XMLStreamConstants.ENTITY_DECLARATION, "ENTITY_DECLARATION");
  }

  private XmlEventUtil() {
    // disable construction
  }

  @SuppressWarnings("null")
  @NonNull
  private static Object escape(@NonNull String data) {
    return data.chars()
        .mapToObj(c -> (char) c)
        .map(XmlEventUtil::escape).collect(Collectors.joining());
  }

  @SuppressWarnings("null")
  @NonNull
  private static String escape(char ch) {
    String retval;
    switch (ch) {
    case '\n':
      retval = "\\n";
      break;
    case '\r':
      retval = "\\r";
      break;
    default:
      retval = String.valueOf(ch);
      break;
    }
    return retval;
  }

  /**
   * Generate a message suitable for logging that describes the provided
   * {@link XMLEvent}.
   *
   * @param xmlEvent
   *          the XML event to generate the message for
   * @return the message
   */
  @NonNull
  public static CharSequence toString(XMLEvent xmlEvent) {
    CharSequence retval;
    if (xmlEvent == null) {
      retval = "EOF";
    } else {
      @SuppressWarnings("null")
      @NonNull
      StringBuilder builder = new StringBuilder()
          .append(toEventName(xmlEvent));
      QName name = toQName(xmlEvent);
      if (name != null) {
        builder.append(": ").append(name.toString());
      }
      if (xmlEvent.isCharacters()) {
        String text = xmlEvent.asCharacters().getData();
        if (text != null) {
          builder.append(" '").append(escape(text)).append('\'');
        }
      }
      Location location = toLocation(xmlEvent);
      if (location != null) {
        builder.append(" at ").append(toString(location));
      }
      retval = builder;
    }
    return retval;
  }

  /**
   * Generates a message for the provided {@link Location}.
   *
   * @param location
   *          the location to generate the message for
   * @return the message
   */
  @SuppressWarnings("null")
  @NonNull
  public static CharSequence toString(@Nullable Location location) {
    return location == null ? "unknown"
        : new StringBuilder()
            .append(location.getLineNumber())
            .append(':')
            .append(location.getColumnNumber());
  }

  /**
   * Generates a string containing the current event and location of the stream
   * reader.
   *
   * @param reader
   *          the XML event stream reader
   * @return the generated string
   */
  @NonNull
  public static CharSequence toString(@NonNull XMLStreamReader2 reader) { // NO_UCD (unused code)
    int type = reader.getEventType();

    @SuppressWarnings("null")
    @NonNull
    StringBuilder builder = new StringBuilder().append(toEventName(type));
    QName name = reader.getName();
    if (name != null) {
      builder.append(": ").append(name.toString());
    }
    if (XMLStreamConstants.CHARACTERS == type) {
      String text = reader.getText();
      if (text != null) {
        builder.append(" '").append(escape(text)).append('\'');
      }
    }
    Location location = reader.getLocation();
    if (location != null) {
      builder.append(" at ").append(toString(location));
    }
    return builder;
  }

  /**
   * Retrieve the resource location of {@code event}.
   *
   * @param event
   *          the XML event to identify the location for
   * @return the location or {@code null} if the location is unknown
   */
  @Nullable
  public static Location toLocation(@NonNull XMLEvent event) {
    Location retval = null;
    if (event.isStartElement()) {
      StartElement start = event.asStartElement();
      retval = start.getLocation();
    } else if (event.isEndElement()) {
      EndElement end = event.asEndElement();
      retval = end.getLocation();
    } else if (event.isCharacters()) {
      Characters characters = event.asCharacters();
      retval = characters.getLocation();
    }
    return retval;
  }

  /**
   * Retrieve the name of the node associated with {@code event}.
   *
   * @param event
   *          the XML event to get the {@link QName} for
   * @return the name of the node or {@code null} if the event is not a start or
   *         end element
   */
  @Nullable
  public static QName toQName(@NonNull XMLEvent event) {
    QName retval = null;
    if (event.isStartElement()) {
      StartElement start = event.asStartElement();
      retval = start.getName();
    } else if (event.isEndElement()) {
      EndElement end = event.asEndElement();
      retval = end.getName();
    }
    return retval;
  }

  /**
   * Get the event name of the {@code event}.
   *
   * @param event
   *          the XML event to get the event name for
   * @return the event name
   */
  @NonNull
  public static String toEventName(@NonNull XMLEvent event) {
    return toEventName(event.getEventType());
  }

  /**
   * Get the event name of the {@code eventType}, which is one of the types
   * defined by {@link XMLStreamConstants}.
   *
   * @param eventType
   *          the event constant to get the event name for as defined by
   *          {@link XMLStreamConstants}
   * @return the event name
   */
  @NonNull
  public static String toEventName(int eventType) {
    String retval = EVENT_NAME_MAP.get(eventType);
    if (retval == null) {
      retval = "unknown event '" + Integer.toString(eventType) + "'";
    }
    return retval;
  }

  /**
   * Advance through XMLEvents until the event type identified by
   * {@code eventType} is reached or the end of stream is found.
   *
   * @param reader
   *          the XML event reader to advance
   * @param eventType
   *          the event type to stop on as defined by {@link XMLStreamConstants}
   * @return the next event of the specified type or {@code null} if the end of
   *         stream is reached
   * @throws XMLStreamException
   *           if an error occurred while advancing the stream
   */
  @Nullable
  public static XMLEvent advanceTo(@NonNull XMLEventReader2 reader, int eventType)
      throws XMLStreamException { // NO_UCD (unused code)
    XMLEvent xmlEvent;
    do {
      xmlEvent = reader.nextEvent();
      // if (LOGGER.isWarnEnabled()) {
      // LOGGER.warn("skipping over: {}", XmlEventUtil.toString(xmlEvent));
      // }
      if (xmlEvent.isStartElement()) {
        advanceTo(reader, XMLStreamConstants.END_ELEMENT);
        // skip this end element
        xmlEvent = reader.nextEvent();
        // if (LOGGER.isDebugEnabled()) {
        // LOGGER.debug("skipping over: {}", XmlEventUtil.toString(xmlEvent));
        // }
      }
    } while (reader.hasNext() && (xmlEvent = reader.peek()).getEventType() != eventType);
    return xmlEvent;
  }

  /**
   * Skip over the next element in the event stream.
   *
   * @param reader
   *          the XML event stream reader
   * @return the next XML event
   * @throws XMLStreamException
   *           if an error occurred while reading the event stream
   */
  @SuppressWarnings("PMD.OnlyOneReturn")
  public static XMLEvent skipElement(@NonNull XMLEventReader2 reader) throws XMLStreamException {
    XMLEvent xmlEvent = reader.peek();
    if (!xmlEvent.isStartElement()) {
      return xmlEvent;
    }
    // if (LOGGER.isInfoEnabled()) {
    // LOGGER.atInfo().log(String.format("At location %s", toString(xmlEvent)));
    // }

    int depth = 0;
    do {
      xmlEvent = reader.nextEvent();
      // if (LOGGER.isInfoEnabled()) {
      // LOGGER.atInfo().log(String.format("Skipping %s", toString(xmlEvent)));
      // }
      if (xmlEvent.isStartElement()) {
        depth++;
      } else if (xmlEvent.isEndElement()) {
        depth--;
      }
    } while (depth > 0 && reader.hasNext());
    return reader.peek();
  }

  /**
   * Skip over any processing instructions.
   *
   * @param reader
   *          the XML event reader to advance
   * @return the last processing instruction event or the reader's next event if
   *         no processing instruction was found
   * @throws XMLStreamException
   *           if an error occurred while advancing the stream
   */
  @NonNull
  public static XMLEvent skipProcessingInstructions(@NonNull XMLEventReader2 reader) throws XMLStreamException {
    XMLEvent nextEvent;
    while ((nextEvent = reader.peek()).isProcessingInstruction()) {
      nextEvent = reader.nextEvent();
    }
    return nextEvent;
  }

  /**
   * Skip over any whitespace.
   *
   * @param reader
   *          the XML event reader to advance
   * @return the last character event containing whitespace or the reader's next
   *         event if no character event was found
   * @throws XMLStreamException
   *           if an error occurred while advancing the stream
   */
  @SuppressWarnings("null")
  @NonNull
  public static XMLEvent skipWhitespace(@NonNull XMLEventReader2 reader) throws XMLStreamException {
    @NonNull
    XMLEvent nextEvent;
    while ((nextEvent = reader.peek()).isCharacters()) {
      Characters characters = nextEvent.asCharacters();
      String data = characters.getData();
      if (!WHITESPACE_ONLY.matcher(data).matches()) {
        break;
      }
      nextEvent = reader.nextEvent();
    }
    return nextEvent;
  }

  /**
   * Determine if the {@code event} is an end element whose name matches the
   * provided {@code expectedQName}.
   *
   * @param event
   *          the XML event
   * @param expectedQName
   *          the expected element name
   * @return {@code true} if the next event matches the {@code expectedQName}
   */
  public static boolean isEventEndElement(XMLEvent event, @NonNull QName expectedQName) {
    return event != null
        && event.isEndElement()
        && expectedQName.equals(event.asEndElement().getName());
  }

  /**
   * Determine if the {@code event} is an end of document event.
   *
   * @param event
   *          the XML event
   * @return {@code true} if the next event is an end of document event
   */
  public static boolean isEventEndDocument(XMLEvent event) {
    return event != null
        && event.isEndElement();
  }

  /**
   * Determine if the {@code event} is a start element whose name matches the
   * provided {@code expectedQName}.
   *
   * @param event
   *          the event
   * @param expectedQName
   *          the expected element name
   * @return {@code true} if the next event is a start element that matches the
   *         {@code expectedQName}
   * @throws XMLStreamException
   *           if an error occurred while looking at the next event
   */
  public static boolean isEventStartElement(XMLEvent event, @NonNull QName expectedQName) throws XMLStreamException {
    return event != null
        && event.isStartElement()
        && expectedQName.equals(event.asStartElement().getName());
  }

  /**
   * Consume the next event from {@code reader} and assert that this event is of
   * the type identified by {@code presumedEventType}.
   *
   * @param reader
   *          the XML event reader
   * @param presumedEventType
   *          the expected event type as defined by {@link XMLStreamConstants}
   * @return the next event
   * @throws XMLStreamException
   *           if an error occurred while looking at the next event
   */
  public static XMLEvent consumeAndAssert(XMLEventReader2 reader, int presumedEventType)
      throws XMLStreamException {
    return consumeAndAssert(reader, presumedEventType, null);
  }

  /**
   * Consume the next event from {@code reader} and assert that this event is of
   * the type identified by {@code presumedEventType} and has the name identified
   * by {@code presumedName}.
   *
   * @param reader
   *          the XML event reader
   * @param presumedEventType
   *          the expected event type as defined by {@link XMLStreamConstants}
   * @param presumedName
   *          the expected name of the node associated with the event
   * @return the next event
   * @throws XMLStreamException
   *           if an error occurred while looking at the next event
   */
  public static XMLEvent consumeAndAssert(XMLEventReader2 reader, int presumedEventType, QName presumedName)
      throws XMLStreamException {
    XMLEvent retval = reader.nextEvent();

    int eventType = retval.getEventType();
    QName name = toQName(retval);
    assert eventType == presumedEventType
        && (presumedName == null
            || presumedName.equals(name)) : generateExpectedMessage(
                retval,
                presumedEventType,
                presumedName);
    return retval;
  }

  /**
   * Ensure that the next event is an XML start element that matches the presumed
   * name.
   *
   * @param reader
   *          the XML event reader
   * @param presumedName
   *          the qualified name of the expected next event
   * @return the XML start element event
   * @throws IOException
   *           if an error occurred while parsing the resource
   * @throws XMLStreamException
   *           if an error occurred while parsing the XML event stream
   */
  @NonNull
  public static StartElement requireStartElement(
      @NonNull XMLEventReader2 reader,
      @NonNull QName presumedName) throws IOException, XMLStreamException {
    XMLEvent retval = reader.nextEvent();
    if (!retval.isStartElement() || !presumedName.equals(retval.asStartElement().getName())) {
      throw new IOException(generateExpectedMessage(
          retval,
          XMLStreamConstants.START_ELEMENT,
          presumedName).toString());
    }
    return ObjectUtils.notNull(retval.asStartElement());
  }

  /**
   * Ensure that the next event is an XML start element that matches the presumed
   * name.
   *
   * @param reader
   *          the XML event reader
   * @param presumedName
   *          the qualified name of the expected next event
   * @return the XML start element event
   * @throws IOException
   *           if an error occurred while parsing the resource
   * @throws XMLStreamException
   *           if an error occurred while parsing the XML event stream
   */
  @NonNull
  public static EndElement requireEndElement(
      @NonNull XMLEventReader2 reader,
      @NonNull QName presumedName) throws IOException, XMLStreamException {
    XMLEvent retval = reader.nextEvent();
    if (!retval.isEndElement() || !presumedName.equals(retval.asEndElement().getName())) {
      throw new IOException(generateExpectedMessage(
          retval,
          XMLStreamConstants.END_ELEMENT,
          presumedName).toString());
    }
    return ObjectUtils.notNull(retval.asEndElement());
  }

  /**
   * Ensure that the next event from {@code reader} is of the type identified by
   * {@code presumedEventType}.
   *
   * @param reader
   *          the event reader
   * @param presumedEventType
   *          the expected event type as defined by {@link XMLStreamConstants}
   * @return the next event
   * @throws XMLStreamException
   *           if an error occurred while looking at the next event
   * @throws AssertionError
   *           if the next event does not match the presumed event
   */
  public static XMLEvent assertNext(
      @NonNull XMLEventReader2 reader,
      int presumedEventType)
      throws XMLStreamException {
    return assertNext(reader, presumedEventType, null);
  }

  /**
   * Ensure that the next event from {@code reader} is of the type identified by
   * {@code presumedEventType} and has the name identified by
   * {@code presumedName}.
   *
   * @param reader
   *          the event reader
   * @param presumedEventType
   *          the expected event type as defined by {@link XMLStreamConstants}
   * @param presumedName
   *          the expected name of the node associated with the event
   * @return the next event
   * @throws XMLStreamException
   *           if an error occurred while looking at the next event
   * @throws AssertionError
   *           if the next event does not match the presumed event
   */
  public static XMLEvent assertNext(
      @NonNull XMLEventReader2 reader,
      int presumedEventType,
      @Nullable QName presumedName)
      throws XMLStreamException {
    XMLEvent nextEvent = reader.peek();

    int eventType = nextEvent.getEventType();
    assert eventType == presumedEventType
        && (presumedName == null
            || presumedName.equals(toQName(nextEvent))) : generateExpectedMessage(
                nextEvent,
                presumedEventType,
                presumedName);
    return nextEvent;
  }

  /**
   * Generate a location string for the current location in the XML event stream.
   *
   * @param event
   *          an XML event
   * @return the location string
   */
  public static CharSequence generateLocationMessage(@NonNull XMLEvent event) {
    Location location = toLocation(event);
    return location == null ? "" : generateLocationMessage(location);
  }

  /**
   * Generate a location string for the current location in the XML event stream.
   *
   * @param location
   *          an XML event stream location
   * @return the location string
   */
  public static CharSequence generateLocationMessage(@NonNull Location location) {
    return new StringBuilder(12)
        .append(" at ")
        .append(XmlEventUtil.toString(location));
  }

  /**
   * Generate a message intended for error reporting based on a presumed event.
   *
   * @param event
   *          the current XML event
   * @param presumedEventType
   *          the expected event type ({@link XMLEvent#getEventType()})
   * @param presumedName
   *          the expected event qualified name or {@code null} if there is no
   *          expectation
   * @return the message string
   */
  public static CharSequence generateExpectedMessage(
      @Nullable XMLEvent event,
      int presumedEventType,
      @Nullable QName presumedName) {
    StringBuilder builder = new StringBuilder(64);
    builder
        .append("Expected XML ")
        .append(toEventName(presumedEventType));

    if (presumedName != null) {
      builder.append(" for QName '")
          .append(presumedName.toString());
    }

    if (event == null) {
      builder.append("', instead found null event");
    } else {
      builder.append("', instead found ")
          .append(toString(event));
    }
    return builder;
  }

  /**
   * Skips events specified by {@code events}.
   *
   * @param reader
   *          the event reader
   * @param events
   *          the events to skip
   * @return the next non-mataching event returned by
   *         {@link XMLEventReader2#peek()}, or {@code null} if there was no next
   *         event
   * @throws XMLStreamException
   *           if an error occurred while reading
   */
  public static XMLEvent skipEvents(XMLEventReader2 reader, int... events) throws XMLStreamException {
    Set<Integer> skipEvents = IntStream.of(events).boxed().collect(Collectors.toSet());

    XMLEvent nextEvent = null;
    while (reader.hasNext()) {
      nextEvent = reader.peek();
      if (!skipEvents.contains(nextEvent.getEventType())) {
        break;
      }
      reader.nextEvent();
    }
    return nextEvent;
  }
}