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}