001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    Copyright © 2018-2024 Open Geospatial Consortium, Inc.
004 *    http://www.geoapi.org
005 *
006 *    Licensed under the Apache License, Version 2.0 (the "License");
007 *    you may not use this file except in compliance with the License.
008 *    You may obtain a copy of the License at
009 *
010 *        http://www.apache.org/licenses/LICENSE-2.0
011 *
012 *    Unless required by applicable law or agreed to in writing, software
013 *    distributed under the License is distributed on an "AS IS" BASIS,
014 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 *    See the License for the specific language governing permissions and
016 *    limitations under the License.
017 */
018package org.opengis.test.dataset;
019
020import java.util.AbstractMap;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.ConcurrentModificationException;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.Iterator;
028import java.util.LinkedHashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.Objects;
032import java.util.Set;
033import java.util.TreeMap;
034import java.lang.reflect.InvocationTargetException;
035import java.lang.reflect.Method;
036import java.lang.reflect.ParameterizedType;
037import java.lang.reflect.Type;
038import java.lang.reflect.WildcardType;
039import org.opengis.annotation.UML;
040import org.opengis.metadata.Metadata;
041import org.opengis.util.GenericName;
042import org.opengis.util.InternationalString;
043import org.opengis.util.ControlledVocabulary;
044import org.opengis.referencing.crs.CoordinateReferenceSystem;
045import org.junit.jupiter.api.Assertions;
046
047
048/**
049 * Verification operations that compare metadata or CRS properties against the expected values.
050 * The metadata or CRS to verify (typically read from a dataset) is specified by a call to one
051 * of the {@code addMetadataToVerify(…)} methods. The expected metadata values are specified by
052 * calls to {@code addExpectedValues(…)} methods. After the expected and actual values have been
053 * specified, they can be compared by a call to {@code assertMetadataEquals()}.
054 *
055 * @author  Martin Desruisseaux (Geomatys)
056 * @version 3.1
057 * @since   3.1
058 */
059public class ContentVerifier {
060    /**
061     * Path to a metadata elements. This is non-empty only while scanning a metadata object by the
062     * {@link #addPropertyValue(Class, Object)} method. Values of this string builders are used as
063     * keys in {@link #metadataValues} map.
064     */
065    private final StringBuilder path;
066
067    /**
068     * Instances already visited, for avoiding never-ending recursive loops. This is non-empty only
069     * while scanning a metadata object by the {@link #addPropertyValue(Class, Object)} method.
070     */
071    private final Set<Element> visited;
072
073    /**
074     * A (class, value) pair where the value is compared by identity. This is used for detecting never-ending loops.
075     * Values shall not be compared with {@link Object#equals(Object)} because we have no guarantee that users wrote
076     * a safe implementation and because it would produce false positives anyway.
077     *
078     * <p>We take in account the type, not only the value instance, because implementations are free to implement
079     * more than one interface with the same class. For example, the same {@code value} instance could implement
080     * both {@code Metadata} and {@code DataIdentification} interfaces.</p>
081     */
082    @SuppressWarnings("doclint:missing")
083    private static final class Element {
084        private final Class<?> type;
085        private final Object value;
086
087        Element(final Class<?> type, final Object value) {
088            this.type  = type;
089            this.value = value;
090        }
091
092        @Override public int hashCode() {
093            return type.hashCode() ^ System.identityHashCode(value);
094        }
095
096        @Override public boolean equals(final Object obj) {
097            if (obj instanceof Element) {
098                final Element other = (Element) obj;
099                return type.equals(other.type) && value == other.value;
100            }
101            return false;
102        }
103    }
104
105    /**
106     * All non-null metadata values found by the {@link #addPropertyValue(Class, Object)} method.
107     */
108    private final Map<String,Object> metadataValues;
109
110    /**
111     * All expected values specified by {@link #addExpectedValue(Map)} method.
112     */
113    private final Map<String,Object> expectedValues;
114
115    /**
116     * Paths of properties that were expected but not found.
117     */
118    private final List<Map.Entry<String,Object>> missings;
119
120    /**
121     * Metadata values that do not match the expected values. We use a {@code List} instead of a {@code Map}
122     * because the same key may appear more than once if the user invokes {@code addMetadataToVerify(…)} and
123     * {@code compareMetadata(…)} many times.
124     */
125    private final List<Map.Entry<String,Object>> mismatches;
126
127    /**
128     * A mismatched value.
129     */
130    private static final class Mismatch {
131        /** The expected metadata value. */ public final Object expected;
132        /** The value found in metadata. */ public final Object actual;
133
134        /**
135         * Creates a new entry for a mismatched value.
136         *
137         * @param expected  the expected metadata value.
138         * @param actual    the value found in metadata.
139         */
140        Mismatch(final Object expected, final Object actual) {
141            this.expected = expected;
142            this.actual   = actual;
143        }
144
145        /** Returns a string representation for debugging purpose. */
146        @Override public String toString() {
147            return toString(new StringBuilder()).toString();
148        }
149
150        /**
151         * Formats the string representation in the given buffer.
152         *
153         * @param  appendTo  where to append the string representation.
154         * @return the given buffer for method calls chaining.
155         */
156        final StringBuilder toString(final StringBuilder appendTo) {
157            final boolean showType = expected != null && actual != null && expected.getClass() != actual.getClass();
158            formatValue(expected, showType, appendTo.append("expected "));
159            formatValue(actual,   showType, appendTo.append(" but was "));
160            return appendTo;
161        }
162    }
163
164    /**
165     * Properties to ignore. They are specified by user with calls to {@link #addPropertyToIgnore(Class, String)}.
166     */
167    private final Map<Class<?>, Set<String>> ignore;
168
169    /**
170     * Creates a new dataset content verifier.
171     */
172    public ContentVerifier() {
173        path           = new StringBuilder(80);
174        visited        = new HashSet<>();
175        metadataValues = new TreeMap<>();
176        expectedValues = new LinkedHashMap<>();
177        mismatches     = new ArrayList<>();
178        missings       = new ArrayList<>();
179        ignore         = new HashMap<>();
180    }
181
182    /**
183     * Resets this verifier to the same state as after construction.
184     * This method can be invoked for reusing the same verifier with different metadata objects.
185     */
186    public void clear() {
187        path.setLength(0);
188        visited.clear();
189        metadataValues.clear();
190        mismatches.clear();
191        missings.clear();
192        ignore.clear();
193    }
194
195    /**
196     * Adds a metadata property to ignore. The property is identified by a GeoAPI interface and the
197     * {@link UML} identifier of a property in that interface. Properties to ignore must be declared
198     * before to invoke {@code addMetadataToVerify(…)}.
199     *
200     * @param  type      GeoAPI interface containing the property to ignore.
201     * @param  property  UML identifier of a property in the given interface.
202     */
203    public void addPropertyToIgnore(final Class<?> type, final String property) {
204        Objects.requireNonNull(type);
205        Objects.requireNonNull(property);
206        Set<String> properties = ignore.get(type);
207        if (properties == null) {
208            properties = new HashSet<>();
209            ignore.put(type, properties);               // TODO: use Map.compureIfAbsent with JDK8.
210        }
211        properties.add(property);
212    }
213
214    /**
215     * Returns {@code true} if the given property shall be ignored.
216     *
217     * @param  type      the type containing the property to filter.
218     * @param  property  the property to filter.
219     * @return whether to ignore the given property.
220     */
221    private boolean isIgnored(final Class<?> type, final UML property) {
222        final Set<String> properties = ignore.get(type);
223        return (properties != null) && properties.contains(property.identifier());
224    }
225
226    /**
227     * Stores all properties of the given metadata, for later comparison against expected values.
228     * If this method is invoked more than once, then the given metadata objects shall not provide values
229     * for the same properties (unless the values are equal, or unless {@link #clear()} has been invoked).
230     *
231     * @param  actual  the metadata read from a dataset, or {@code null} if none.
232     * @throws IllegalStateException if the given metadata contains a property already found in a previous
233     *         call to this method, and the values found in those two invocations are not equal.
234     */
235    public void addMetadataToVerify(final Metadata actual) {
236        explode(Metadata.class, actual);
237    }
238
239    /**
240     * Stores all properties of the given CRS, for later comparison against expected values.
241     * In this class, a Coordinate Reference System is considered as a kind of metadata.
242     * If this method is invoked more than once, then the given CRS objects shall not provide values
243     * for the same properties (unless the values are equal, or unless {@link #clear()} has been invoked).
244     *
245     * @param  actual  the CRS read from a dataset, or {@code null} if none.
246     * @throws IllegalStateException if the given CRS contains a property already found in a previous
247     *         call to this method, and the values found in those two invocations are not equal.
248     */
249    public void addMetadataToVerify(final CoordinateReferenceSystem actual) {
250        explode(CoordinateReferenceSystem.class, actual);
251    }
252
253    /**
254     * Adds a snapshot of the given object for later comparison against expected values.
255     *
256     * @param  <T>     compile time value of {@code type}.
257     * @param  type    the GeoAPI interface implemented by the given object.
258     * @param  actual  the metadata or CRS read from a dataset, or {@code null} if none.
259     * @throws IllegalStateException if the given object contains a property already found in a previous
260     *         call to this method, and the values found in those two invocations are not equal.
261     */
262    private <T> void explode(final Class<T> type, final T actual) {
263        if (actual != null) try {
264            addPropertyValue(type, actual);
265        } catch (InvocationTargetException e) {
266            Throwable cause = e.getTargetException();
267            if (cause instanceof RuntimeException) {
268                throw (RuntimeException) cause;
269            } else if (cause instanceof Error) {
270                throw (Error) cause;
271            } else {
272                throw new RuntimeException(cause);
273            }
274        } catch (IllegalAccessException e) {
275            throw new AssertionError(e);                    // Should never happen since we invoked only public methods.
276        } finally {
277            path.setLength(0);
278            visited.clear();
279        }
280    }
281
282    /**
283     * Returns the sub-interfaces implemented by the given implementation class. For example if a property type
284     * is {@code CoordinateReferenceSystem}, a given instance could implement the {@code GeographicCRS} subtype.
285     *
286     * @param  baseType        the property type.
287     * @param  implementation  the class which may implement a specialized type.
288     * @return the given type or one of its subtypes implemented by the given class.
289     */
290    private static Class<?> specialized(final Class<?> baseType, Class<?> implementation) {
291        do {
292            for (final Class<?> s : implementation.getInterfaces()) {
293                if (baseType.isAssignableFrom(s) && s.isAnnotationPresent(UML.class)) {
294                    return s;
295                }
296            }
297            implementation = implementation.getSuperclass();
298        } while (implementation != null);
299        return baseType;
300    }
301
302    /**
303     * Adds the given value in the {@link #metadataValues} map. If the given value is another metadata object,
304     * then this method iterates recursively over all elements in that metadata. The key is the current value
305     * of {@link #path}.
306     *
307     * @param  type  the GeoAPI interface implemented by the given object, or the standard Java class if not a metadata type.
308     * @param  obj   non-null instance of {@code type} to add in the map.
309     * @throws InvocationTargetException if an error occurred while invoking client code.
310     * @throws IllegalAccessException if the method to invoke is not public (should never happen with interfaces).
311     * @throws IllegalStateException if a different metadata value is already presents for the current {@link #path} key.
312     */
313    private void addPropertyValue(Class<?> type, final Object obj) throws InvocationTargetException, IllegalAccessException {
314        if (InternationalString.class.isAssignableFrom(type) ||        // Most common case first.
315           ControlledVocabulary.class.isAssignableFrom(type) ||
316                    GenericName.class.isAssignableFrom(type) ||
317                       !type.isAnnotationPresent(UML.class))
318        {
319            final String key = path.toString();
320            final Object previous = metadataValues.put(key, obj);
321            if (previous != null && !previous.equals(obj)) {
322                throw new IllegalStateException(String.format("Metadata element \"%s\" is specified twice "
323                        + "with two different values:%nValue 1: %s%nValue 2: %s%n", key, previous, obj));
324            }
325        } else {
326            final Element recursivityGuard = new Element(type, obj);
327            if (visited.add(recursivityGuard)) {
328                final int pathElementPosition = path.length();
329                type = specialized(type, obj.getClass());               // Example: Identification may actually be DataIdentification
330                for (final Method getter : type.getMethods()) {
331                    if (getter.getParameterTypes().length != 0) {       // TODO: use getParameterCount() with JDK8.
332                        continue;
333                    }
334                    if (getter.isAnnotationPresent(Deprecated.class)) {
335                        continue;
336                    }
337                    final UML spec = getter.getAnnotation(UML.class);
338                    if (spec == null || isIgnored(type, spec)) {
339                        continue;
340                    }
341                    Class<?> valueType = getter.getReturnType();
342                    if (Void.TYPE.equals(valueType)) {
343                        continue;
344                    }
345                    final Object value = getter.invoke(obj, (Object[]) null);
346                    if (value == null) {
347                        continue;
348                    }
349                    final Iterator<?> values;
350                    if (Map.class.isAssignableFrom(valueType)) {
351                        values = ((Map<?,?>) value).keySet().iterator();
352                        if (!values.hasNext()) continue;
353                    } else if (Iterable.class.isAssignableFrom(valueType)) {
354                        values = ((Iterable<?>) value).iterator();
355                        if (!values.hasNext()) continue;
356                    } else {
357                        values = null;
358                    }
359                    if (pathElementPosition != 0) {
360                        path.append('.');
361                    }
362                    path.append(spec.identifier());
363                    if (values == null) {
364                        addPropertyValue(valueType, value);
365                    } else {
366                        valueType = boundOfParameterizedProperty(getter.getGenericReturnType());
367                        final int indexPosition = path.append('[').length();
368                        int i = 0;
369                        do {
370                            path.append(i++).append(']');
371                            addPropertyValue(valueType, values.next());
372                            path.setLength(indexPosition);
373                        } while (values.hasNext());
374                    }
375                    path.setLength(pathElementPosition);
376                }
377                if (!visited.remove(recursivityGuard)) {
378                    // Should never happen unless the map is modified concurrently in another thread.
379                    throw new ConcurrentModificationException();
380                }
381            }
382        }
383    }
384
385    /**
386     * Returns the upper bounds of the parameterized type. For example if a method returns {@code Collection<String>},
387     * then {@code boundOfParameterizedProperty(method.getGenericReturnType())} should return {@code String.class}.
388     *
389     * @param  type  the type for which to get parameterized bound type.
390     * @return the parameterized bound type.
391     */
392    private static Class<?> boundOfParameterizedProperty(Type type) {
393        if (type instanceof ParameterizedType) {
394            Type[] p = ((ParameterizedType) type).getActualTypeArguments();
395            if (p != null && p.length == 2) {
396                final Type raw = ((ParameterizedType) type).getRawType();
397                if (raw instanceof Class<?> && Map.class.isAssignableFrom((Class<?>) raw)) {
398                    /*
399                     * If the type is a map, keep only the first type parameter (for keys type).
400                     * The type that we retain here must be consistent with the choice of iterator
401                     * (keys or values) done in above addPropertyValue(…) method.
402                     */
403                    p = Arrays.copyOf(p, 1);
404                }
405            }
406            while (p != null && p.length == 1) {
407                type = p[0];
408                if (type instanceof WildcardType) {
409                    p = ((WildcardType) type).getUpperBounds();
410                } else {
411                    if (type instanceof ParameterizedType) {
412                        type = ((ParameterizedType) type).getRawType();
413                    }
414                    if (type instanceof Class<?>) {
415                        return (Class<?>) type;
416                    }
417                    break;                              // Unknown type.
418                }
419            }
420        }
421        throw new IllegalArgumentException("Cannot find the parameterized type of " + type);
422    }
423
424    /**
425     * Returns {@code true} if the given value should be considered as a "primitive" for formatting purpose.
426     * Primitive are null, numbers or booleans, but we extend this definition to enumerations and code lists.
427     *
428     * @param  value  the value to test.
429     * @return whether the specified value is a primitive for formatting purpose.
430     */
431    private static boolean isPrimitive(final Object value) {
432        return (value == null) || (value instanceof ControlledVocabulary)
433                || (value instanceof Number) || (value instanceof Boolean);
434    }
435
436    /**
437     * Implementation of {@code compareMetadata(…)} public methods. This implementation removes properties
438     * from the given map as they are found. After this method completed, the remaining entries in the given
439     * map are properties not found in the metadata given to {@code addMetadataToVerify(…)} methods.
440     *
441     * @param  entries  the metadata properties to compare, in a modifiable map (will be modified).
442     * @return {@code true} if all properties match, with no missing property and no unexpected property.
443     *
444     * @see #compareMetadata()
445     */
446    private boolean filterProperties(final Set<Map.Entry<String,Object>> entries) {
447        final Iterator<Map.Entry<String,Object>> it = entries.iterator();
448        while (it.hasNext()) {
449            final Map.Entry<String,Object> entry = it.next();
450            final String key = entry.getKey();
451            final Object actual = metadataValues.remove(key);
452            if (actual != null) {
453                it.remove();
454                final Object expected = entry.getValue();
455                if (Objects.equals(expected, actual)) {
456                    continue;
457                } else if (expected instanceof Number && actual instanceof Number) {
458                    if (expected instanceof Float) {
459                        if (Float.floatToIntBits((Float) expected) ==
460                            Float.floatToIntBits(((Number) actual).floatValue()))
461                        {
462                            continue;
463                        }
464                    } else if (expected instanceof Double) {
465                        if (Double.doubleToLongBits((Double) expected) ==
466                            Double.doubleToLongBits(((Number) actual).doubleValue()))
467                        {
468                            continue;
469                        }
470                    }
471                } else if (expected instanceof CharSequence) {
472                    // The main intent is to convert InternationalString.
473                    if (Objects.equals(expected.toString(), actual.toString())) {
474                        continue;
475                    }
476                }
477                mismatches.add(new AbstractMap.SimpleEntry<String,Object>(key, new Mismatch(expected, actual)));
478            }
479        }
480        missings.addAll(entries);
481        return mismatches.isEmpty() && metadataValues.isEmpty() && entries.isEmpty();
482    }
483
484    /**
485     * Adds the expected metadata value for the given path.
486     * The path is a string like the following examples
487     * ({@code [0]} is the index of an element in lists or collections):
488     *
489     * <ul>
490     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
491     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
492     * </ul>
493     *
494     * The {@code expectedValue} argument is the expected values for the properties identified by the path.
495     *
496     * @param  path           path to the property to verify.
497     * @param  expectedValue  the expected values of property identified by the path.
498     * @throws IllegalArgumentException if a different value is already declared for the given path.
499     */
500    public void addExpectedValue(final String path, final Object expectedValue) {
501        final Object previous = expectedValues.putIfAbsent(path, expectedValue);
502        if (previous != null && !previous.equals(expectedValue)) {
503            throw new IllegalArgumentException(String.format("Metadata element \"%s\" is specified twice:"
504                    + "%nValue 1: %s%nValue 2: %s%n", path, previous, expectedValue));
505        }
506    }
507
508    /**
509     * Adds the expected metadata values for the given paths.
510     * The {@code path} argument identifies a metadata element like the following examples
511     * ({@code [0]} is the index of an element in lists or collections):
512     *
513     * <ul>
514     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
515     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
516     * </ul>
517     *
518     * The {@code value} argument is the expected value for the property identified by the path.
519     *
520     * @param  path           path of the property to compare.
521     * @param  expectedValue  expected value for the property at the given path.
522     * @param  others         other ({@code path}, {@code expectedValue}) pairs.
523     * @throws ClassCastException if an {@code others} element for a path is not a {@link String} instance.
524     * @throws IllegalArgumentException if a path is already associated to a different value.
525     */
526    public void addExpectedValues(final String path, final Object expectedValue, final Object... others) {
527        addExpectedValue(path, expectedValue);
528        for (int i=0; i<others.length; i++) {
529            final Object key = others[i];
530            if (!(key instanceof String)) {
531                throw new ClassCastException(String.format("others[%d] shall be a String, but given value class is %s.",
532                            i, (key != null) ? key.getClass() : null));
533            }
534            addExpectedValue((String) key, others[++i]);
535        }
536    }
537
538    /**
539     * Adds the expected metadata values for the given paths.
540     * For each entry in the map, the key is a path to a metadata element like the following examples
541     * ({@code [0]} is the index of an element in lists or collections):
542     *
543     * <ul>
544     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
545     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
546     * </ul>
547     *
548     * Values in the map are the expected values for the properties identified by the keys.
549     *
550     * @param  expected  the expected values of properties identified by the keys.
551     * @throws IllegalArgumentException if a path is already associated to a different value.
552     */
553    public void addExpectedValues(final Map<String,?> expected) {
554        for (final Map.Entry<String,?> entry : expected.entrySet()) {
555            addExpectedValue(entry.getKey(), entry.getValue());
556        }
557    }
558
559    /**
560     * Compares actual metadata properties against the expected values given in a map.
561     * The {@code addMetadataToVerify(…)} and {@code addExpectedValues(…)} methods
562     * must be invoked before this method.
563     * Comparison result can be viewed after this method call with {@link #toString()}.
564     *
565     * @return {@code true} if all properties match, with no missing property and no unexpected property.
566     */
567    public boolean compareMetadata() {
568        return filterProperties(expectedValues.entrySet());
569    }
570
571    /**
572     * Asserts that actual metadata properties are equal to the expected values.
573     * The {@code addMetadataToVerify(…)} and {@code addExpectedValues(…)} methods
574     * must be invoked before this method.
575     * If there is any <em>mismatched</em>, <em>missing</em> or <em>unexpected</em> value,
576     * then the assertion fails with an error message listing all differences found.
577     */
578    public void assertMetadataEquals() {
579        if (!compareMetadata()) {
580            Assertions.fail(toString());
581        }
582    }
583
584    /**
585     * Returns a string representation of the comparison results.
586     * This method formats up to three blocks in a JSON-like format:
587     *
588     * <ul>
589     *   <li>List of actual values that do no match the expected values.</li>
590     *   <li>List of expected values that are missing in the actual values.</li>
591     *   <li>List of actual values that were unexpected.</li>
592     * </ul>
593     *
594     * @return a string representation of the comparison results.
595     */
596    @Override
597    public String toString() {
598        final StringBuilder buffer = new StringBuilder();
599        final String lineSeparator = System.lineSeparator();
600        formatTable("mismatches", mismatches,                buffer, lineSeparator);
601        formatTable("missings",   missings,                  buffer, lineSeparator);
602        formatTable("unexpected", metadataValues.entrySet(), buffer, lineSeparator);
603        return buffer.length() != 0 ? buffer.toString() : "No difference found.";
604    }
605
606    /**
607     * Formats the given entry as a table in the given {@link StringBuilder}.
608     *
609     * @param label          table label.
610     * @param values         values to format.
611     * @param appendTo       where to write the value.
612     * @param lineSeparator  value of {@link System#lineSeparator()}.
613     */
614    private static void formatTable(final String label, final Collection<Map.Entry<String,Object>> values,
615            final StringBuilder appendTo, final String lineSeparator)
616    {
617        if (!values.isEmpty()) {
618            appendTo.append(label).append(" = {").append(lineSeparator);
619            int width = -1;
620            for (final Map.Entry<String,Object> entry : values) {
621                final int length = entry.getKey().length();
622                if (length > width) width = length;
623            }
624            width++;
625            for (final Map.Entry<String,Object> entry : values) {
626                final String key = entry.getKey();
627                appendTo.append("    \"").append(key).append("\":");
628                for (int i = width - key.length(); --i >= 0;) {
629                    appendTo.append(' ');
630                }
631                final Object value = entry.getValue();
632                if (value instanceof Mismatch) {
633                    ((Mismatch) value).toString(appendTo);
634                } else {
635                    formatValue(value, false, appendTo);
636                }
637                appendTo.append(',').append(lineSeparator);
638            }
639            if (width > 0) {                                                                // Paranoiac check.
640                appendTo.deleteCharAt(appendTo.length() - lineSeparator.length() - 1);      // Remove last comma.
641            }
642            appendTo.append('}').append(lineSeparator);
643        }
644    }
645
646    /**
647     * Formats the given value in the given buffer, eventually between quotes.
648     *
649     * @param value     value to format.
650     * @param showType  whether to format the value type.
651     * @param appendTo  where to write the value.
652     */
653    private static void formatValue(final Object value, final boolean showType, final StringBuilder appendTo) {
654        final boolean quote = !isPrimitive(value);
655        if (quote) appendTo.append('"');
656        appendTo.append(value);
657        if (quote) appendTo.append('"');
658        if (showType) {
659            appendTo.append(" (an instance of ").append(value.getClass().getSimpleName()).append(')');
660        }
661    }
662}