/*
* Geotoolkit.org - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2014, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.io.yaml;
import java.util.Collection;
import java.util.ConcurrentModificationException;
import java.util.Date;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.Map;
import java.io.IOException;
import org.opengis.util.ControlledVocabulary;
import org.apache.sis.measure.Angle;
import org.apache.sis.metadata.AbstractMetadata;
import org.apache.sis.metadata.KeyNamePolicy;
import org.apache.sis.metadata.MetadataStandard;
import org.apache.sis.metadata.ValueExistencePolicy;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.iso.Types;
/**
* Formats objects in the JSON format.
*
* @author Martin Desruisseaux (Geomatys)
* @module
*
* @todo We have an ambiguity when writing {@code Party.name}: nothing distinguish an individual name
* from an organization name, since the {@code Party} type is lost at JSON writing time.
* We propose to format as {@code "individual.name"} and {@code "organisation.name"} in those
* particular cases.
*/
final class Writer {
/**
* The length of an escaped Unicode character.
*/
static final int UNICODE_LENGTH = 4;
/**
* Number of spaces to use for the indentation.
*/
private static final int INDENTATION = 4;
/**
* Where to write the formatted output.
*/
private final Appendable out;
/**
* The line-separator to use.
*/
private final String lineSeparator;
/**
* The ISO standard to use for listing the object properties.
*/
private MetadataStandard standard;
/**
* Number of spaces to write before the first no-space character.
* This value is increased or decreased when an object definition
* begins or stops.
*/
private int margin;
/**
* Guard against infinite recursivity.
*/
private final Map<Object,Object> guard = new IdentityHashMap<>();
/**
* Creates a new writer for the given standard which will write in the given stream.
*
* @param out Where to format the object.
*/
public Writer(final Appendable out) {
this.out = out;
lineSeparator = System.lineSeparator();
}
/**
* Suggests a metadata standard for the given object.
*/
private static MetadataStandard getStandard(final Object value) {
return (value instanceof AbstractMetadata) ? ((AbstractMetadata) value).getStandard() : MetadataStandard.ISO_19115;
}
/**
* Writes the left margin at the beginning of a new line.
*/
private void indent() throws IOException {
out.append(CharSequences.spaces(margin));
}
/**
* Formats the given object in the output stream specified at construction time.
*
* @param object The object to format.
* @throws ClassCastException If the given object is not an instance of a recognized standard.
* @throws IOException If an error occurred while writing to the output stream.
*/
public void format(final Object object) throws ClassCastException, IOException {
if (guard.put(object, Boolean.TRUE) == null) {
final MetadataStandard previous = standard;
standard = getStandard(object);
formatEntries(standard.asValueMap(object, KeyNamePolicy.UML_IDENTIFIER,
ValueExistencePolicy.NON_EMPTY).entrySet().iterator());
standard = previous;
if (guard.remove(object) != Boolean.TRUE) { // Identity check is okay here.
throw new ConcurrentModificationException();
}
} else {
// We have a recursivity, buy we can not express that in JSON.
out.append(null);
}
}
/**
* Formats all (key, value) pairs of the given map, in iteration order.
*
* @param it An iterator over the metadata properties to format.
* @throws IOException If an error occurred while writing to the output stream.
*/
private void formatEntries(final Iterator<Map.Entry<String,Object>> it) throws IOException {
out.append('{').append(lineSeparator);
margin += INDENTATION;
for (boolean hasNext = it.hasNext(); hasNext;) {
final Map.Entry<String,Object> entry = it.next();
hasNext = it.hasNext();
indent();
out.append('"');
escape(entry.getKey());
out.append("\": ");
final Object value = entry.getValue();
if (value == null) {
out.append(null);
} else if (value instanceof Collection<?>) {
formatArray(((Collection<?>) value).iterator());
} else {
formatValue(value);
}
if (hasNext) {
out.append(',');
}
out.append(lineSeparator);
}
margin -= INDENTATION;
indent();
out.append('}');
}
/**
* Formats an array of values.
*
* @param it An iterator over the array elements to format.
* @throws IOException If an error occurred while writing to the output stream.
*/
private void formatArray(final Iterator<?> it) throws IOException {
out.append('[');
boolean hasNext = it.hasNext();
while (hasNext) {
final Object element = it.next();
hasNext = it.hasNext();
formatValue(element);
if (hasNext) {
out.append(',');
}
}
out.append(']');
}
/**
* Formats a single value.
*
* @param value The value to format.
* @throws IOException If an error occurred while writing to the output stream.
*/
private void formatValue(final Object value) throws IOException {
if (standard.isMetadata(value.getClass())) {
format(value);
} else {
final String text;
final boolean quote;
if (value instanceof ControlledVocabulary) {
text = Types.getCodeName((ControlledVocabulary) value);
quote = true;
} else if (value instanceof Date) {
text = Long.toString(((Date) value).getTime());
quote = false;
} else if (value instanceof Angle) {
text = Double.toString(((Angle) value).degrees());
quote = false;
} else {
text = value.toString();
quote = !(value instanceof Number || value instanceof Boolean);
}
if (quote) out.append('"');
escape(text);
if (quote) out.append('"');
}
}
/**
* Appends the given text, escaping characters if needed.
*/
private void escape(final String text) throws IOException {
int previous = 0;
final int length = text.length();
for (int i=0; i<length; i++) {
final char c = text.charAt(i); // No need for codepoint API in this method.
final char r;
switch (c) {
case '"' : // Fallthrough
case '\\': r = c; break;
case '\b': r = 'b'; break;
case '\f': r = 'f'; break;
case '\n': r = 'n'; break;
case '\r': r = 'r'; break;
case '\t': r = 't'; break;
default: {
if (!Character.isISOControl(c)) {
continue; // Nothing to escape.
}
r = 'u';
break;
}
}
out.append(text, previous, i);
out.append('\\').append(r);
if (r == 'u') {
final String h = Integer.toHexString(c);
for (int p=h.length(); p<UNICODE_LENGTH; p++) {
out.append('0');
}
out.append(h);
}
previous = i+1;
}
out.append(text, previous, length);
}
}