001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau;
018
019import static org.apache.juneau.commons.utils.IoUtils.*;
020
021import java.util.regex.*;
022
023/**
024 * Shared test-support utilities for validating XML output.
025 */
026@SuppressWarnings({
027   "java:S112" // checkXmlWhitespace() and its helper deliberately throw plain Exception to report whitespace errors.
028})
029public final class XmlTestUtils {
030
031   // Whitespace-validation patterns.  The lookbehind on the trailing '>' distinguishes start tags ('(?<!/)')
032   // from self-closing tags ('(?<=/)') in a single linear pass, avoiding the super-linear backtracking of the
033   // previous '.*'/nested-quantifier forms (java:S8786).  Hoisted to static final fields since they're
034   // stateless and otherwise get recompiled on every checkXmlWhitespace() call/recursion.
035   private static final Pattern START_TAG = Pattern.compile("^(\\s*)<[^/>][^>]*(?<!/)>$");
036   private static final Pattern END_TAG = Pattern.compile("^(\\s*)</[^>]+>$");
037   private static final Pattern COMBINED_TAG = Pattern.compile("^(\\s*)<[^/>][^>]*(?<=/)>$");
038   private static final Pattern CONTENT_ONLY = Pattern.compile("^(\\s*)[^\\s\\<]+$");
039
040   private XmlTestUtils() {}
041
042   /**
043    * Validates that the whitespace is correct in the specified XML.
044    *
045    * @param out The XML to validate.
046    * @throws Exception If the whitespace/indentation in the XML is incorrect.
047    */
048   public static void checkXmlWhitespace(String out) throws Exception {
049      if (out.indexOf('\u0000') != -1) {
050         for (var s : out.split("\u0000"))
051            checkXmlWhitespace(s);
052         return;
053      }
054
055      var lines = out.split("\n");
056      var indent = -1;
057      try {
058         for (var i = 0; i < lines.length; i++)
059            indent = checkLineIndent(lines[i], i + 1, indent);
060         if (indent != -1)
061            throw new Exception("Possible unmatched tag.  indent=''" + indent + "''");
062      } catch (Exception e) {
063         printLines(lines);
064         throw e;
065      }
066   }
067
068   /**
069    * Validates the indentation of a single line of XML and returns the indent depth after processing it.
070    *
071    * @param line The line to validate.
072    * @param lineNum The 1-based line number (for error messages).
073    * @param indent The indent depth before processing this line.
074    * @return The indent depth after processing this line.
075    * @throws Exception If the line's indentation is wrong, or the line doesn't match any recognized form.
076    */
077   private static int checkLineIndent(String line, int lineNum, int indent) throws Exception {
078      var m = START_TAG.matcher(line);
079      if (m.matches()) {
080         checkIndent(indent + 1, m.group(1).length(), lineNum, "start tag");
081         return indent + 1;
082      }
083
084      m = END_TAG.matcher(line);
085      if (m.matches()) {
086         checkIndent(indent, m.group(1).length(), lineNum, "end tag");
087         return indent - 1;
088      }
089
090      m = COMBINED_TAG.matcher(line);
091      if (m.matches()) {
092         checkIndent(indent + 1, m.group(1).length(), lineNum, "combined tag");
093         return indent;
094      }
095
096      m = CONTENT_ONLY.matcher(line);
097      if (m.matches()) {
098         checkIndent(indent + 1, m.group(1).length(), lineNum, "content-only");
099         return indent;
100      }
101
102      var twc = tagWithContentIndent(line);
103      if (twc != -1) {
104         checkIndent(indent + 1, twc, lineNum, "tag-with-content");
105         return indent;
106      }
107
108      throw new Exception("Unmatched whitespace line at line number ''" + lineNum + "''");
109   }
110
111   /**
112    * Verifies that an observed indent width matches the expected one, throwing a descriptive exception otherwise.
113    *
114    * @param expected The expected indent width.
115    * @param actual The observed indent width.
116    * @param lineNum The 1-based line number (for error messages).
117    * @param lineType A human-readable description of the line kind being checked (for error messages).
118    * @throws Exception If <c>actual != expected</c>.
119    */
120   private static void checkIndent(int expected, int actual, int lineNum, String lineType) throws Exception {
121      if (actual != expected)
122         throw new Exception("Wrong indentation detected on " + lineType + " line ''" + lineNum + "''");
123   }
124
125   /**
126    * Returns the leading-whitespace indent width of a line that is a single tag-with-content line.
127    *
128    * <p>
129    * A tag-with-content line is one that — after any optional leading whitespace — starts with an opening tag and
130    * ends with a closing tag, such as <c>&lt;foo&gt;bar&lt;/foo&gt;</c>.  This is a linear-time replacement for the
131    * <c>^(\s*)&lt;[^&gt;]+&gt;.*&lt;/[^&gt;]+&gt;$</c> regular expression that avoids its super-linear backtracking.
132    *
133    * @param line The line to test.
134    * @return
135    *    The number of leading whitespace characters on the line, or <c>-1</c> if the line is not a single
136    *    tag-with-content line.
137    */
138   private static int tagWithContentIndent(String line) {
139      var n = line.length();
140      var w = 0;
141      while (w < n) {
142         var c = line.charAt(w);
143         if (c != ' ' && c != '\t' && c != '\n' && c != '\u000B' && c != '\f' && c != '\r')
144            break;
145         w++;
146      }
147      if (n - w < 2 || line.charAt(w) != '<' || line.charAt(n - 1) != '>')
148         return -1;
149      var open = line.indexOf('>', w);          // End of the opening tag.
150      if (open - w < 2)
151         return -1;
152      var close = line.lastIndexOf("</");        // Start of the final closing tag.
153      if (close <= open || close + 3 > n - 1)
154         return -1;
155      for (var j = close + 2; j < n - 1; j++)    // Closing tag name must not contain '>'.
156         if (line.charAt(j) == '>')
157            return -1;
158      return w;
159   }
160}