diff --git a/src/com/google/common/css/MinimalSubstitutionMap.java b/src/com/google/common/css/MinimalSubstitutionMap.java index 176c9ce5..1033e588 100644 --- a/src/com/google/common/css/MinimalSubstitutionMap.java +++ b/src/com/google/common/css/MinimalSubstitutionMap.java @@ -20,7 +20,6 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; - import java.util.Arrays; import java.util.Map; import java.util.Set; @@ -31,7 +30,7 @@ * * @author bolinfest@google.com (Michael Bolin) */ -public class MinimalSubstitutionMap implements SubstitutionMap { +public class MinimalSubstitutionMap implements SubstitutionMap.Initializable { /** Possible first chars in a CSS class name */ private static final char[] START_CHARS = { @@ -91,7 +90,7 @@ public class MinimalSubstitutionMap implements SubstitutionMap { /** * A set of CSS class names that may not be output from this substitution map. */ - private final Set outputValueBlacklist; + private ImmutableSet outputValueBlacklist; public MinimalSubstitutionMap() { this(ImmutableSet.of()); @@ -156,6 +155,14 @@ public String get(String key) { return value; } + @Override + public void initializeWithMappings(Map m) { + Preconditions.checkState(renamedCssClasses.isEmpty()); + this.outputValueBlacklist = + ImmutableSet.builder().addAll(outputValueBlacklist).addAll(m.values()).build(); + this.renamedCssClasses.putAll(m); + } + /** * Converts a 32-bit integer to a unique short string whose first character * is in {@link #START_CHARS} and whose subsequent characters, if any, are diff --git a/src/com/google/common/css/OutputRenamingMapFormat.java b/src/com/google/common/css/OutputRenamingMapFormat.java index 5b393c9e..ca163b92 100644 --- a/src/com/google/common/css/OutputRenamingMapFormat.java +++ b/src/com/google/common/css/OutputRenamingMapFormat.java @@ -16,14 +16,25 @@ package com.google.common.css; + import com.google.common.base.Preconditions; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; +import com.google.common.escape.CharEscaperBuilder; +import com.google.common.escape.Escaper; +import com.google.common.io.CharStreams; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; - +import com.google.gson.JsonParser; +import java.io.BufferedReader; +import java.io.IOException; import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; import java.util.Iterator; import java.util.Map; import java.util.Properties; @@ -52,36 +63,14 @@ public enum OutputRenamingMapFormat { CLOSURE_COMPILED_BY_WHOLE("goog.setCssNameMapping(%s, 'BY_WHOLE');\n"), /** - * Before writing the mapping as CLOSURE_COMPILED, split the css name maps - * by hyphens and write out each piece individually. see - * {@code CLOSURE_COMPILED} + * Before writing the mapping as CLOSURE_COMPILED, split the css name maps by hyphens and write + * out each piece individually. see {@code CLOSURE_COMPILED} */ - CLOSURE_COMPILED_SPLIT_HYPHENS { + CLOSURE_COMPILED_SPLIT_HYPHENS("goog.setCssNameMapping(%s);\n") { @Override - public void writeRenamingMap(Map renamingMap, - PrintWriter renamingMapWriter) { - Map newSplitRenamingMap = Maps.newHashMap(); - for (Map.Entry entry : renamingMap.entrySet()) { - Iterator parts = - HYPHEN_SPLITTER.split(entry.getKey()).iterator(); - Iterator partsNew = - HYPHEN_SPLITTER.split(entry.getValue()).iterator(); - while (parts.hasNext() && partsNew.hasNext()) { - newSplitRenamingMap.put(parts.next(), partsNew.next()); - } - if (parts.hasNext()) { - throw new AssertionError("Not all parts of the original class " - + "name were output. Class: " + entry.getKey() + " Next Part:" - + parts.next()); - } - if (partsNew.hasNext()) { - throw new AssertionError("Not all parts of the renamed class were " - + "output. Class: " + entry.getKey() + " Renamed Class: " - + entry.getValue() + " Next Part:" + partsNew.next()); - } - } - OutputRenamingMapFormat.CLOSURE_COMPILED.writeRenamingMap( - newSplitRenamingMap, renamingMapWriter); + public void writeRenamingMap(Map renamingMap, Writer renamingMapWriter) + throws IOException { + super.writeRenamingMap(splitEntriesOnHyphens(renamingMap), renamingMapWriter); } }, @@ -103,17 +92,22 @@ public void writeRenamingMap(Map renamingMap, */ PROPERTIES { @Override - public void writeRenamingMap(Map renamingMap, - PrintWriter renamingMapWriter) { + public void writeRenamingMap(Map renamingMap, Writer renamingMapWriter) + throws IOException { + writeOnePerLine('=', renamingMap, renamingMapWriter); // We write the properties directly rather than using // Properties#store() because it is impossible to suppress the timestamp // comment: http://goo.gl/6hsrN. As noted on the Stack Overflow thread, // the timestamp results in unnecessary diffs between runs. Further, those // who are using a language other than Java to parse this file should not // have to worry about adding support for comments. - for (Map.Entry entry : renamingMap.entrySet()) { - renamingMapWriter.format("%s=%s\n", entry.getKey(), entry.getValue()); - } + } + + @Override + void readMapInto( + BufferedReader in, ImmutableMap.Builder builder) + throws IOException { + readOnePerLine('=', in, builder); } }, @@ -123,16 +117,18 @@ public void writeRenamingMap(Map renamingMap, */ JSCOMP_VARIABLE_MAP { @Override - public void writeRenamingMap(Map renamingMap, - PrintWriter renamingMapWriter) { - for (Map.Entry entry : renamingMap.entrySet()) { - renamingMapWriter.format("%s:%s\n", entry.getKey(), entry.getValue()); - } + public void writeRenamingMap(Map renamingMap, Writer renamingMapWriter) + throws IOException { + writeOnePerLine(':', renamingMap, renamingMapWriter); } - }; - // Splitter used for CLOSURE_COMPILED_SPLIT_HYPHENS format. - private static final Splitter HYPHEN_SPLITTER = Splitter.on("-"); + @Override + void readMapInto( + BufferedReader in, ImmutableMap.Builder builder) + throws IOException { + readOnePerLine(':', in, builder); + } + }; private final String formatString; @@ -146,11 +142,13 @@ private OutputRenamingMapFormat() { } /** + * Writes the renaming map. + * * @see com.google.common.css.compiler.commandline.DefaultCommandLineCompiler * #writeRenamingMap(Map, PrintWriter) */ - public void writeRenamingMap(Map renamingMap, - PrintWriter renamingMapWriter) { + public void writeRenamingMap(Map renamingMap, Writer renamingMapWriter) + throws IOException { // Build up the renaming map as a JsonObject. JsonObject properties = new JsonObject(); for (Map.Entry entry : renamingMap.entrySet()) { @@ -162,4 +160,170 @@ public void writeRenamingMap(Map renamingMap, renamingMapWriter.write(String.format(formatString, gson.toJson(properties))); } + + /** + * Like {@writeRenamingMap(java.util.Map, java.io.Writer)} but does not throw when writes fail. + */ + public final void writeRenamingMap( + Map renamingMap, PrintWriter renamingMapWriter) { + try { + writeRenamingMap(renamingMap, (Writer) renamingMapWriter); + } catch (IOException ex) { + throw (AssertionError) new AssertionError("IOException from PrintWriter").initCause(ex); + } + } + + /** + * Reads the output of {@link #writeRenamingMap} so a renaming map can be reused from one compile + * to another. + */ + public ImmutableMap readRenamingMap(Reader in) throws IOException { + String subsitutionMarker = "%s"; + int formatStringSubstitutionIndex = formatString.indexOf(subsitutionMarker); + Preconditions.checkState(formatStringSubstitutionIndex >= 0, formatString); + + String formatPrefix = formatString.substring(0, formatStringSubstitutionIndex); + String formatSuffix = + formatString.substring(formatStringSubstitutionIndex + subsitutionMarker.length()); + + // GSON's JSONParser does not stop reading bytes when it sees a bracket that + // closes the value. + // We read the whole input in, then strip prefixes and suffixes and then parse + // the rest. + String content = CharStreams.toString(in); + + content = content.trim(); + formatPrefix = formatPrefix.trim(); + formatSuffix = formatSuffix.trim(); + + if (!content.startsWith(formatPrefix) + || !content.endsWith(formatSuffix) + || content.length() < formatPrefix.length() + formatSuffix.length()) { + throw new IOException("Input does not match format " + formatString + " : " + content); + } + + content = content.substring(formatPrefix.length(), content.length() - formatSuffix.length()); + + ImmutableMap.Builder b = ImmutableMap.builder(); + BufferedReader br = new BufferedReader(new StringReader(content)); + readMapInto(br, b); + requireEndOfInput(br); + + return b.build(); + } + + /** + * Reads the mapping portion of the formatted output. + * + *

This default implementation works for formats that substitute a JSON mapping from rewritten + * names to originals into their format string, and may be overridden by formats that do something + * different. + */ + void readMapInto(BufferedReader in, ImmutableMap.Builder builder) + throws IOException { + JsonElement json = new JsonParser().parse(in); + for (Map.Entry e : json.getAsJsonObject().entrySet()) { + builder.put(e.getKey(), e.getValue().getAsString()); + } + } + + /** + * Raises an IOException if there are any non-space characters on in, and consumes the remaining + * characters on in. + */ + private static void requireEndOfInput(BufferedReader in) throws IOException { + for (int ch; (ch = in.read()) >= 0; ) { + if (!Character.isSpace((char) ch)) { + throw new IOException("Expected end of input, not '" + escape((char) ch) + "'"); + } + } + } + + private static final Escaper ESCAPER = + new CharEscaperBuilder() + .addEscape('\t', "\\t") + .addEscape('\n', "\\n") + .addEscape('\r', "\\r") + .addEscape('\\', "\\\\") + .addEscape('\'', "\\'") + .toEscaper(); + + private static String escape(char ch) { + return ESCAPER.escape(new String(new char[] {ch})); + } + + /** Splitter used for CLOSURE_COMPILED_SPLIT_HYPHENS format. */ + private static final Splitter HYPHEN_SPLITTER = Splitter.on("-"); + + /** + * { "foo-bar": "f-b" } => { "foo": "f", "bar": "b" }. + * + * @see SplittingSubstitutionMap + */ + private static Map splitEntriesOnHyphens(Map renamingMap) { + Map newSplitRenamingMap = Maps.newLinkedHashMap(); + for (Map.Entry entry : renamingMap.entrySet()) { + Iterator keyParts = HYPHEN_SPLITTER.split(entry.getKey()).iterator(); + Iterator valueParts = HYPHEN_SPLITTER.split(entry.getValue()).iterator(); + while (keyParts.hasNext() && valueParts.hasNext()) { + String keyPart = keyParts.next(); + String valuePart = valueParts.next(); + String oldValuePart = newSplitRenamingMap.put(keyPart, valuePart); + // Splitting by part to make a simple map shouldn't involve mapping two old names + // to the same new name. It's ok the other way around, but the part relation should + // be a partial function. + Preconditions.checkState(oldValuePart == null || oldValuePart.equals(valuePart)); + } + if (keyParts.hasNext()) { + throw new AssertionError( + "Not all parts of the original class " + + "name were output. Class: " + + entry.getKey() + + " Next Part:" + + keyParts.next()); + } + if (valueParts.hasNext()) { + throw new AssertionError( + "Not all parts of the renamed class were " + + "output. Class: " + + entry.getKey() + + " Renamed Class: " + + entry.getValue() + + " Next Part:" + + valueParts.next()); + } + } + return newSplitRenamingMap; + } + + private static void writeOnePerLine( + char separator, Map renamingMap, Writer renamingMapWriter) + throws IOException { + for (Map.Entry entry : renamingMap.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + Preconditions.checkState(key.indexOf(separator) < 0); + Preconditions.checkState(key.indexOf('\n') < 0); + Preconditions.checkState(value.indexOf('\n') < 0); + + renamingMapWriter.write(key); + renamingMapWriter.write(separator); + renamingMapWriter.write(value); + renamingMapWriter.write('\n'); + } + } + + private static void readOnePerLine( + char separator, + BufferedReader in, + ImmutableMap.Builder builder) + throws IOException { + for (String line; (line = in.readLine()) != null; ) { + int eq = line.indexOf(separator); + if (eq < 0 && !line.isEmpty()) { + throw new IOException("Line is missing a '" + separator + "': " + line); + } + builder.put(line.substring(0, eq), line.substring(eq + 1)); + } + } } diff --git a/src/com/google/common/css/PrefixingSubstitutionMap.java b/src/com/google/common/css/PrefixingSubstitutionMap.java index e4f1c694..b8858d29 100644 --- a/src/com/google/common/css/PrefixingSubstitutionMap.java +++ b/src/com/google/common/css/PrefixingSubstitutionMap.java @@ -16,12 +16,15 @@ package com.google.common.css; +import java.util.Map; + /** - * A {@link SubstitutionMap} implementation that prefixes the renamed CSS class - * names (provided by a delegate substitution map). + * A {@link SubstitutionMap} implementation that prefixes the renamed CSS class names (provided by a + * delegate substitution map). * */ -public class PrefixingSubstitutionMap implements SubstitutionMap { +public class PrefixingSubstitutionMap + implements MultipleMappingSubstitutionMap, SubstitutionMap.Initializable { private final SubstitutionMap delegate; private final String prefix; @@ -30,8 +33,29 @@ public PrefixingSubstitutionMap(SubstitutionMap delegate, String prefix) { this.prefix = prefix; } + @Override + public void initializeWithMappings(Map newMappings) { + if (!newMappings.isEmpty()) { + // We don't need to remove prefixes from mapping values because the mappings + // returned by getValueWithMappings are not prefixed. + ((SubstitutionMap.Initializable) delegate).initializeWithMappings(newMappings); + } + } + @Override public String get(String key) { return prefix + delegate.get(key); } + + @Override + public ValueWithMappings getValueWithMappings(String key) { + if (delegate instanceof MultipleMappingSubstitutionMap) { + ValueWithMappings withoutPrefix = + ((MultipleMappingSubstitutionMap) delegate).getValueWithMappings(key); + return ValueWithMappings.createWithValueAndMappings( + prefix + withoutPrefix.value, withoutPrefix.mappings); + } else { + return ValueWithMappings.createForSingleMapping(key, get(key)); + } + } } diff --git a/src/com/google/common/css/RecordingSubstitutionMap.java b/src/com/google/common/css/RecordingSubstitutionMap.java index 56b658df..13397396 100644 --- a/src/com/google/common/css/RecordingSubstitutionMap.java +++ b/src/com/google/common/css/RecordingSubstitutionMap.java @@ -19,52 +19,40 @@ import com.google.common.base.Preconditions; import com.google.common.base.Predicate; +import com.google.common.base.Predicates; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.common.css.MultipleMappingSubstitutionMap.ValueWithMappings; - import java.util.Map; -import javax.annotation.Nullable; - /** * A decorator for a {@link SubstitutionMap} that records which values it maps. * * @author bolinfest@google.com (Michael Bolin) */ -public class RecordingSubstitutionMap implements SubstitutionMap { +public class RecordingSubstitutionMap implements SubstitutionMap.Initializable { private final SubstitutionMap delegate; private final Predicate shouldRecordMappingForCodeGeneration; - private final Map mappings; - - /** - * If defined, this should be used rather than delegate to look up a mapping. - */ - @Nullable - private final MultipleMappingSubstitutionMap multipleMappingSubstitutionMap; + // Use a LinkedHashMap so getMappings() is deterministic. + private final Map mappings = Maps.newLinkedHashMap(); /** * Creates a new instance. + * * @param map A SubstitutionMap decorated by this class - * @param shouldRecordMappingForCodeGeneration A predicate that returns true - * if the mapping should be recorded for code-generation purposes + * @param shouldRecordMappingForCodeGeneration A predicate that returns true if the mapping should + * be recorded for code-generation purposes + * @deprecated Use {@link Builder} instead. */ - public RecordingSubstitutionMap(SubstitutionMap map, - Predicate shouldRecordMappingForCodeGeneration) { + @Deprecated + public RecordingSubstitutionMap( + SubstitutionMap map, Predicate shouldRecordMappingForCodeGeneration) { this.delegate = map; this.shouldRecordMappingForCodeGeneration = shouldRecordMappingForCodeGeneration; - - // Use a LinkedHashMap so getMappings() is deterministic. - this.mappings = Maps.newLinkedHashMap(); - - this.multipleMappingSubstitutionMap = - (map instanceof MultipleMappingSubstitutionMap) - ? (MultipleMappingSubstitutionMap)map - : null; } /** @@ -78,24 +66,85 @@ public String get(String key) { return key; } - if (mappings.containsKey(key)) { - return mappings.get(key); - } else if (multipleMappingSubstitutionMap != null) { - ValueWithMappings valueWithMappings = multipleMappingSubstitutionMap - .getValueWithMappings(key); + if (delegate instanceof MultipleMappingSubstitutionMap) { + // The final value only bears a loose relationship to the mappings. + // For example, PrefixingSubstitutionMap applied to a MinimalSubstitutionMap + // minimizes all components but only prefixes the first. + // We can't memoize the value here, so don't look up in mappings first. + ValueWithMappings valueWithMappings = + ((MultipleMappingSubstitutionMap) delegate).getValueWithMappings(key); mappings.putAll(valueWithMappings.mappings); return valueWithMappings.value; } else { - String value = delegate.get(key); - mappings.put(key, value); + String value = mappings.get(key); + if (value == null) { + value = delegate.get(key); + mappings.put(key, value); + } return value; } } /** - * @return The recorded mappings in the order they were created. + * @return The recorded mappings in the order they were created. This output may be used with + * {@link OutputRenamingMapFormat#writeRenamingMap} */ - public Map getMappings() { + public Map getMappings() { return ImmutableMap.copyOf(mappings); } + + @Override + public void initializeWithMappings(Map newMappings) { + Preconditions.checkState(mappings.isEmpty()); + if (!newMappings.isEmpty()) { + mappings.putAll(newMappings); + ((SubstitutionMap.Initializable) delegate).initializeWithMappings(newMappings); + } + } + + /** A-la-carte builder. */ + public static final class Builder { + private SubstitutionMap delegate = new IdentitySubstitutionMap(); + private Predicate shouldRecordMappingForCodeGeneration = + Predicates.alwaysTrue(); + private Map mappings = Maps.newLinkedHashMap(); + + /** Specifies the underlying map. Multiple calls clobber. */ + public Builder withSubstitutionMap(SubstitutionMap d) { + this.delegate = Preconditions.checkNotNull(d); + return this; + } + + /** + * True keys that should be treated mapped to themselves instead of passing through Multiple + * calls AND. + */ + public Builder shouldRecordMappingForCodeGeneration(Predicate p) { + shouldRecordMappingForCodeGeneration = + Predicates.and(shouldRecordMappingForCodeGeneration, p); + return this; + } + + /** + * Specifies mappings to {@linkplain Initializable initialize} the delegate with. Multiple calls + * putAll. This can be used to reconstitute a map that was written out by {@link + * OutputRenamingMapFormat#writeRenamingMap} from the output of {@link + * OutputRenamingMapFormat#readRenamingMap}. + */ + public Builder withMappings(Map m) { + this.mappings.putAll(m); + return this; + } + + /** Builds the substitution map based on previous operations on this builder. */ + public RecordingSubstitutionMap build() { + // TODO(msamuel): if delegate instanceof MultipleMappingSubstitutionMap + // should this return a RecordingSubstitutionMap that is itself + // a MultipleMappingSubstitutionMap. + RecordingSubstitutionMap built = + new RecordingSubstitutionMap(delegate, shouldRecordMappingForCodeGeneration); + built.initializeWithMappings(mappings); + return built; + } + } } diff --git a/src/com/google/common/css/SplittingSubstitutionMap.java b/src/com/google/common/css/SplittingSubstitutionMap.java index 77bfb868..12b17f9c 100644 --- a/src/com/google/common/css/SplittingSubstitutionMap.java +++ b/src/com/google/common/css/SplittingSubstitutionMap.java @@ -20,7 +20,6 @@ import com.google.common.base.Splitter; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; - import java.util.Map; /** @@ -30,7 +29,7 @@ * @author dgajda@google.com (Damian Gajda) */ public class SplittingSubstitutionMap implements - MultipleMappingSubstitutionMap { + MultipleMappingSubstitutionMap, SubstitutionMap.Initializable { private static final Splitter DASH = Splitter.on('-'); private final SubstitutionMap delegate; @@ -38,6 +37,13 @@ public SplittingSubstitutionMap(SubstitutionMap substitutionMap) { this.delegate = substitutionMap; } + @Override + public void initializeWithMappings(Map newMappings) { + if (!newMappings.isEmpty()) { + ((SubstitutionMap.Initializable) delegate).initializeWithMappings(newMappings); + } + } + @Override public String get(String key) { return getValueWithMappings(key).value; @@ -57,7 +63,7 @@ public ValueWithMappings getValueWithMappings(String key) { StringBuilder buffer = new StringBuilder(); // Cannot use an ImmutableMap.Builder because the same key/value pair may be // inserted more than once in this loop. - Map mappings = Maps.newHashMap(); + Map mappings = Maps.newLinkedHashMap(); for (String part : DASH.split(key)) { if (buffer.length() != 0) { buffer.append('-'); diff --git a/src/com/google/common/css/SubstitutionMap.java b/src/com/google/common/css/SubstitutionMap.java index 8ed949f0..b7d7f980 100644 --- a/src/com/google/common/css/SubstitutionMap.java +++ b/src/com/google/common/css/SubstitutionMap.java @@ -16,6 +16,8 @@ package com.google.common.css; +import java.util.Map; + /** * An interface for a one-to-one string mapping function. */ @@ -30,4 +32,34 @@ public interface SubstitutionMap { * @return the value to substitute for {@code key} */ String get(String key); + + + /** + * A substitution map that can be reconsitituted from saved mappings. + * + *

This allows re-using a substitution map across multiple compile + * steps, and allows stability when incrementally recompiling part of + * a project. + * + *

Compilation starts with no rename map on disk, in which case + * no call to this method is necessary. + * + *

After the first compilation, + * {@link RecordingSubstitutionMap#getRenameMap} may be used with + * an {@link OutputRenamingMapFormat} to serialize the renaming map + * in a form that can be used with Closure Library code or Closure + * Templates. + * + *

Before a re-compile, {@link OutputRenamingMapFormat#readRenamingMap} + * can be used to generate a set of initial mappings that can be + * passed to {@link Initializable#initializeWithMappings} to prepare + * a structurally equivalent substitution map to produce IDs that do + * not conflict with those generated by the one used for the previous + * compile. + * + *

Subsequent com + */ + interface Initializable extends SubstitutionMap { + void initializeWithMappings(Map initialMappings); + } } diff --git a/tests/com/google/common/css/RecordingSubstitutionMapTest.java b/tests/com/google/common/css/RecordingSubstitutionMapTest.java new file mode 100644 index 00000000..ab985249 --- /dev/null +++ b/tests/com/google/common/css/RecordingSubstitutionMapTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.common.css; + +import com.google.common.collect.ImmutableSet; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import junit.framework.AssertionFailedError; +import junit.framework.TestCase; + +public final class RecordingSubstitutionMapTest extends TestCase { + + private static final ImmutableSet OUTPUT_BLACKLIST = ImmutableSet.of("c", "e", "i"); + + private static SubstitutionMap createDelegate() { + // Test a whole mess of delegate classes. + SubstitutionMap m = new MinimalSubstitutionMap(OUTPUT_BLACKLIST); + m = new SplittingSubstitutionMap(m); + m = new PrefixingSubstitutionMap(m, "x-"); + return m; + } + + public final void testReadAndWrite() throws IOException { + for (OutputRenamingMapFormat format : OutputRenamingMapFormat.values()) { + RecordingSubstitutionMap recording = + new RecordingSubstitutionMap.Builder().withSubstitutionMap(createDelegate()).build(); + // Put some stuff into the map. + // TIL: there are a lot of websites on the A-Z of fruits & vegetables. + assertEquals("x-a", recording.get("apple")); + assertEquals("x-b", recording.get("banana")); + assertEquals("x-d", recording.get("durian")); + assertEquals("x-f-g", recording.get("figgy-goop")); + + // Write it out. + StringWriter out = new StringWriter(); + String formatted; + try { + format.writeRenamingMap(recording.getMappings(), out); + formatted = out.toString(); + } finally { + out.close(); + } + + // Reconstitute it. + RecordingSubstitutionMap recordingFromString; + StringReader in = new StringReader(formatted); + try { + recordingFromString = + new RecordingSubstitutionMap.Builder() + .withSubstitutionMap(createDelegate()) + .withMappings(format.readRenamingMap(in)) + .build(); + assertEquals(-1, in.read()); + } catch (IOException | RuntimeException ex) { + throw (AssertionFailedError) + new AssertionFailedError( + "Problem with input formatted according to " + + format + + "\n```\n" + + formatted + + "\n```") + .initCause(ex); + } finally { + in.close(); + } + + // Vary the order to check that we get stable values from the + // earlier uses, and unambiguous new values. + assertEquals("x-b", recordingFromString.get("banana")); + assertEquals("x-h", recordingFromString.get("honeydew")); + assertEquals("x-a", recordingFromString.get("apple")); + assertEquals("x-f-g", recordingFromString.get("figgy-goop")); + assertEquals("x-d", recordingFromString.get("durian")); + assertEquals("x-j", recordingFromString.get("jalapeno")); + } + } +}