001package org.hl7.fhir.r5.test.utils;
002
003import org.apache.commons.codec.binary.Base64;
004import org.apache.commons.lang3.StringUtils;
005import org.hl7.fhir.exceptions.FHIRException;
006import org.hl7.fhir.r5.model.Constants;
007import org.hl7.fhir.utilities.*;
008import org.hl7.fhir.utilities.filesystem.CSFile;
009import org.hl7.fhir.utilities.filesystem.ManagedFileAccess;
010import org.hl7.fhir.utilities.json.JsonUtilities;
011import org.hl7.fhir.utilities.json.model.JsonArray;
012import org.hl7.fhir.utilities.json.model.JsonElement;
013import org.hl7.fhir.utilities.json.model.JsonNull;
014import org.hl7.fhir.utilities.json.model.JsonObject;
015import org.hl7.fhir.utilities.json.model.JsonPrimitive;
016import org.hl7.fhir.utilities.json.model.JsonProperty;
017import org.hl7.fhir.utilities.json.parser.JsonParser;
018import org.hl7.fhir.utilities.settings.FhirSettings;
019import org.hl7.fhir.utilities.xml.XMLUtil;
020import org.w3c.dom.Document;
021import org.w3c.dom.Element;
022import org.w3c.dom.NamedNodeMap;
023import org.w3c.dom.Node;
024
025import org.hl7.fhir.utilities.tests.BaseTestingUtilities;
026
027import javax.xml.parsers.DocumentBuilder;
028import javax.xml.parsers.DocumentBuilderFactory;
029import java.io.*;
030import java.util.ArrayList;
031import java.util.HashMap;
032import java.util.List;
033import java.util.Map;
034import java.util.Set;
035
036public class CompareUtilities extends BaseTestingUtilities {
037
038  private static final boolean SHOW_DIFF = false;
039  private JsonObject externals;
040  private Map<String, String> variables;
041  private Set<String> modes;
042  private boolean patternMode; 
043
044  public CompareUtilities() {
045    super();
046    this.variables = new HashMap<String, String>();
047  }
048
049  public CompareUtilities(Set<String> modes) {
050    super();
051    this.modes = modes;
052    this.variables = new HashMap<String, String>();
053  }
054  
055  public CompareUtilities(Set<String> modes, JsonObject externals) {
056    super();
057    this.modes = modes;
058    this.externals = externals;
059    this.variables = new HashMap<String, String>();
060  }
061  
062  public CompareUtilities(Set<String> modes, JsonObject externals, Map<String, String> variables) {
063    super();
064    this.externals = externals;
065    this.variables = variables;
066  }
067
068  /**
069   * in pattern mode, the comparison is only looking to find the expected properties. anything else is ignored
070   * @return
071   */
072  public boolean isPatternMode() {
073    return patternMode;
074  }
075
076  public CompareUtilities setPatternMode(boolean patternMode) {
077    this.patternMode = patternMode;
078    return this;
079  }
080
081  public String createNotEqualMessage(String id, final String message, final String expected, final String actual) {
082    if (patternMode) {
083    return new StringBuilder()
084        .append(message).append(". ")
085        .append("Expected:").append(presentExpected(expected)).append(" for "+id).append(". ")
086        .append("Actual  :").append("\""+actual+"\"").toString();
087    } else {
088      return new StringBuilder()
089          .append(message).append('\n')
090          .append("Expected:").append(presentExpected(expected)).append(" for "+id).append('\n')
091          .append("Actual  :").append("\""+actual+"\"").toString();
092    }
093  }
094
095  private String presentExpected(String expected) {
096    if (expected == null) {
097      return "null";
098    } else if (expected.startsWith("$") && expected.endsWith("$")) {
099      if (expected.startsWith("$choice:")) {
100        return "Contains one of "+readChoices(expected.substring(8, expected.length()-1)).toString();
101      } else if (expected.startsWith("$fragments:")) {
102        List<String> fragments = readChoices(expected.substring(11, expected.length()-1));
103        return "Contains all of "+fragments.toString();
104      } else if (expected.startsWith("$external:")) {
105        String[] cmd = expected.substring(1, expected.length() - 1).split(":");
106        if (externals != null) {
107          String s = externals.asString(cmd[1]);
108          return "\""+s+"\" (Ext)";
109        } else {
110          List<String> fragments = readChoices(cmd[2]);
111          return "Contains all of "+fragments.toString()+" (because no external string provided for "+cmd[1]+")";
112        }
113      } else {
114        switch (expected) {
115        case "$$" : return "$$";
116        case "$instant$": return "\"An Instant\"";
117        case "$date$": return "\"A date\"";
118        case "$uuid$": return "\"A Uuid\"";
119        case "$string$": return "\"A string\"";
120        case "$id$": return "\"An Id\"";
121        case "$url$": return "\"A URL\"";
122        case "$token$": return "\"A Token\"";
123        case "$version$": return variables.containsKey("version") ? variables.get("version") : "(anything)";
124        case "$semver$": return "A semver"; 
125        default: return "Unhandled template: "+expected;
126        }
127      }
128    } else {
129      return "\""+expected+"\"";
130    }
131  }
132
133  public String checkXMLIsSame(String id, InputStream expected, InputStream actual) throws Exception {
134    String result = compareXml(id, expected, actual);
135    return result;
136  }
137
138  public String checkXMLIsSame(String id, String expected, String actual) throws Exception {
139    String result = compareXml(id, expected, actual);
140    if (result != null && SHOW_DIFF) {
141      String diff = getDiffTool();
142      if (diff != null && ManagedFileAccess.file(diff).exists() || Utilities.isToken(diff)) {
143        Runtime.getRuntime().exec(new String[]{diff, expected, actual});
144      }
145    }
146    return result;
147  }
148
149  private String getDiffTool() throws IOException {
150    if (FhirSettings.hasDiffToolPath()) {
151      return FhirSettings.getDiffToolPath();
152    } else if (System.getenv("ProgramFiles") != null) { 
153      return Utilities.path(System.getenv("ProgramFiles"), "WinMerge", "WinMergeU.exe");
154    } else {
155      return null;
156    }
157  }
158
159  private String compareXml(String id, InputStream expected, InputStream actual) throws Exception {
160    return compareElements(id, "", loadXml(expected).getDocumentElement(), loadXml(actual).getDocumentElement());
161  }
162
163  private String compareXml(String id, String expected, String actual) throws Exception {
164    return compareElements(id, "", loadXml(expected).getDocumentElement(), loadXml(actual).getDocumentElement());
165  }
166
167  private String compareElements(String id, String path, Element expectedElement, Element actualElement) {
168    if (!namespacesMatch(expectedElement.getNamespaceURI(), actualElement.getNamespaceURI()))
169      return createNotEqualMessage(id, "Namespaces differ at " + path, expectedElement.getNamespaceURI(), actualElement.getNamespaceURI());
170    if (!expectedElement.getLocalName().equals(actualElement.getLocalName()))
171      return createNotEqualMessage(id, "Names differ at " + path ,  expectedElement.getLocalName(), actualElement.getLocalName());
172    path = path + "/" + expectedElement.getLocalName();
173    String s = compareAttributes(id, path, expectedElement.getAttributes(), actualElement.getAttributes());
174    if (!Utilities.noString(s))
175      return s;
176    s = compareAttributes(id, path, expectedElement.getAttributes(), actualElement.getAttributes());
177    if (!Utilities.noString(s))
178      return s;
179
180    Node expectedChild = expectedElement.getFirstChild();
181    Node actualChild = actualElement.getFirstChild();
182    expectedChild = skipBlankText(expectedChild);
183    actualChild = skipBlankText(actualChild);
184    while (expectedChild != null && actualChild != null) {
185      if (expectedChild.getNodeType() != actualChild.getNodeType())
186        return createNotEqualMessage(id, "node type mismatch in children of " + path, Short.toString(expectedElement.getNodeType()), Short.toString(actualElement.getNodeType()));
187      if (expectedChild.getNodeType() == Node.TEXT_NODE) {
188        if (!normalise(expectedChild.getTextContent()).equals(normalise(actualChild.getTextContent())))
189          return createNotEqualMessage(id, "Text differs at " + path, normalise(expectedChild.getTextContent()).toString(), normalise(actualChild.getTextContent()).toString());
190      } else if (expectedChild.getNodeType() == Node.ELEMENT_NODE) {
191        s = compareElements(id, path, (Element) expectedChild, (Element) actualChild);
192        if (!Utilities.noString(s))
193          return s;
194      }
195
196      expectedChild = skipBlankText(expectedChild.getNextSibling());
197      actualChild = skipBlankText(actualChild.getNextSibling());
198    }
199    if (expectedChild != null)
200      return "node mismatch - more nodes in actual in children of " + path;
201    if (actualChild != null)
202      return "node mismatch - more nodes in expected in children of " + path;
203    return null;
204  }
205
206  private boolean namespacesMatch(String ns1, String ns2) {
207    return ns1 == null ? ns2 == null : ns1.equals(ns2);
208  }
209
210  private String normalise(String text) {
211    String result = text.trim().replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
212    while (result.contains("  "))
213      result = result.replace("  ", " ");
214    return result;
215  }
216
217  private String compareAttributes(String id, String path, NamedNodeMap expected, NamedNodeMap actual) {
218    for (int i = 0; i < expected.getLength(); i++) {
219
220      Node expectedNode = expected.item(i);
221      String expectedNodeName = expectedNode.getNodeName();
222      if (!(expectedNodeName.equals("xmlns") || expectedNodeName.startsWith("xmlns:"))) {
223        Node actualNode = actual.getNamedItem(expectedNodeName);
224        if (actualNode == null)
225          return "Attributes differ at " + path + ": missing attribute " + expectedNodeName;
226        if (!normalise(expectedNode.getTextContent()).equals(normalise(actualNode.getTextContent()))) {
227          byte[] b1 = unBase64(expectedNode.getTextContent());
228          byte[] b2 = unBase64(actualNode.getTextContent());
229          if (!sameBytes(b1, b2))
230            return createNotEqualMessage(id, "Attributes differ at " + path, normalise(expectedNode.getTextContent()).toString(), normalise(actualNode.getTextContent()).toString()) ;
231        }
232      }
233    }
234    return null;
235  }
236
237  private boolean sameBytes(byte[] b1, byte[] b2) {
238    if (b1.length == 0 || b2.length == 0)
239      return false;
240    if (b1.length != b2.length)
241      return false;
242    for (int i = 0; i < b1.length; i++)
243      if (b1[i] != b2[i])
244        return false;
245    return true;
246  }
247
248  private byte[] unBase64(String text) {
249    return Base64.decodeBase64(text);
250  }
251
252  private Node skipBlankText(Node node) {
253    while (node != null && (((node.getNodeType() == Node.TEXT_NODE) && StringUtils.isWhitespace(node.getTextContent())) || (node.getNodeType() == Node.COMMENT_NODE)))
254      node = node.getNextSibling();
255    return node;
256  }
257
258  private Document loadXml(String fn) throws Exception {
259    return loadXml(ManagedFileAccess.inStream(fn));
260  }
261
262  private Document loadXml(InputStream fn) throws Exception {
263    DocumentBuilderFactory factory = XMLUtil.newXXEProtectedDocumentBuilderFactory();
264    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
265    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
266    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
267    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
268    factory.setXIncludeAware(false);
269    factory.setExpandEntityReferences(false);
270
271    factory.setNamespaceAware(true);
272    DocumentBuilder builder = factory.newDocumentBuilder();
273    return builder.parse(fn);
274  }
275
276  public String checkJsonSrcIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
277    return checkJsonSrcIsSame(id, expected, actual, true);
278  }
279
280  public String checkJsonSrcIsSame(String id, String expectedString, String actualString, boolean showDiff) throws FileNotFoundException, IOException {
281    String result = compareJsonSrc(id, expectedString, actualString);
282    if (result != null && SHOW_DIFF && showDiff) {
283      String diff = null;
284      if (System.getProperty("os.name").contains("Linux"))
285        diff = Utilities.path("/", "usr", "bin", "meld");
286      else if (System.getenv("ProgramFiles(X86)") != null) {
287        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
288          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
289        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
290          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
291      }
292      if (diff == null || diff.isEmpty())
293        return result;
294
295      List<String> command = new ArrayList<String>();
296      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
297      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
298      FileUtilities.stringToFile(expectedString, expected);
299      FileUtilities.stringToFile(actualString, actual);
300      command.add(diff);
301      if (diff.toLowerCase().contains("meld"))
302        command.add("--newtab");
303      command.add(expected);
304      command.add(actual);
305
306      ProcessBuilder builder = new ProcessBuilder(command);
307      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
308      builder.start();
309
310    }
311    return result;
312  }
313
314  public String checkJsonIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
315    String result = compareJson(id, expected, actual);
316    if (result != null && SHOW_DIFF) {
317      String diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
318      List<String> command = new ArrayList<String>();
319      command.add("\"" + diff + "\" \"" + expected +  "\" \"" + actual + "\"");
320
321      ProcessBuilder builder = new ProcessBuilder(command);
322      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
323      builder.start();
324
325    }
326    return result;
327  }
328
329  private String compareJsonSrc(String id, String expected, String actual) throws FileNotFoundException, IOException {
330    JsonObject actualJsonObject = JsonParser.parseObject(actual);
331    JsonObject expectedJsonObject = JsonParser.parseObject(expected);
332    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
333  }
334
335  private String compareJson(String id, String expected, String actual) throws FileNotFoundException, IOException {
336    JsonObject actualJsonObject = JsonParser.parseObject(FileUtilities.fileToString(actual));
337    JsonObject expectedJsonObject = JsonParser.parseObject(FileUtilities.fileToString(expected));
338    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
339  }
340
341  private String compareObjects(String id, String path, JsonObject expectedJsonObject, JsonObject actualJsonObject) {
342    List<String> optionals = listOptionals(expectedJsonObject);
343    List<String> countOnlys = listCountOnlys(expectedJsonObject);
344    for (JsonProperty en : actualJsonObject.getProperties()) {
345      String n = en.getName();
346      if (!n.equals("fhir_comments")) {
347        if (expectedJsonObject.has(n)) {
348          String s = compareNodes(id, path + '.' + n, expectedJsonObject.get(n), en.getValue(), countOnlys.contains(n), n, actualJsonObject);
349          if (!Utilities.noString(s))
350            return s;
351        } else if (!patternMode) {
352          return "properties differ at " + path + ": unexpected property " + n;
353        }
354      }
355    }
356    for (JsonProperty en : expectedJsonObject.getProperties()) {
357      String n = en.getName();
358      if (!n.equals("fhir_comments") && !isOptional(n, optionals)) {
359        if (!actualJsonObject.has(n) && !allOptional(en.getValue()))
360          return "properties differ at " + path + ": missing property " + n;
361      }
362    }
363    return null;
364  }
365
366  private boolean isOptional(String n, List<String> optionals) {
367    return n.equals("$optional$") || optionals.contains("*")  || optionals.contains(n);
368  }
369
370  private boolean allOptional(JsonElement value) {
371    if (value.isJsonArray()) {
372      JsonArray a = value.asJsonArray();
373      for (JsonElement e : a) {
374        if (e.isJsonObject()) {
375          JsonObject o = e.asJsonObject();
376          if (!o.has("$optional$")) {
377            return false;
378          }
379        } else {
380          // nothing
381        }
382      }
383      return true;
384    } else {
385      return false;
386    }
387  }
388
389  private List<String> listOptionals(JsonObject expectedJsonObject) {
390    List<String> res = new ArrayList<>();
391    if (expectedJsonObject.has("$optional-properties$")) {
392      res.add("$optional-properties$");
393      res.add("$count-arrays$");
394      for (String s : expectedJsonObject.getStrings("$optional-properties$")) {
395        res.add(s);
396      }
397    }
398    return res;
399  }
400
401  private List<String> listCountOnlys(JsonObject expectedJsonObject) {
402    List<String> res = new ArrayList<>();
403    if (expectedJsonObject.has("$count-arrays$")) {
404      for (String s : expectedJsonObject.getStrings("$count-arrays$")) {
405        res.add(s);
406      }
407    }
408    return res;
409  }
410
411  private String compareNodes(String id, String path, JsonElement expectedJsonElement, JsonElement actualJsonElement, boolean countOnly, String name, JsonObject parent) {
412    if (!(expectedJsonElement instanceof JsonPrimitive && actualJsonElement instanceof JsonPrimitive)) {
413      if (actualJsonElement.getClass() != expectedJsonElement.getClass()) {
414        return createNotEqualMessage(id, "properties differ at " + path, expectedJsonElement.getClass().getName(), actualJsonElement.getClass().getName());
415      }
416    }
417    if (actualJsonElement instanceof JsonPrimitive) {
418      JsonPrimitive actualJsonPrimitive = (JsonPrimitive) actualJsonElement;
419      JsonPrimitive expectedJsonPrimitive = (JsonPrimitive) expectedJsonElement;
420      if (actualJsonPrimitive.isJsonBoolean() && expectedJsonPrimitive.isJsonBoolean()) {
421        if (actualJsonPrimitive.asBoolean() != expectedJsonPrimitive.asBoolean())
422          return createNotEqualMessage(id, "boolean property values differ at " + path , expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
423      } else if (actualJsonPrimitive.isJsonString() && expectedJsonPrimitive.isJsonString()) {
424        String actualJsonString = actualJsonPrimitive.asString();
425        String expectedJsonString = expectedJsonPrimitive.asString();
426        if (!(actualJsonString.contains("<div") && expectedJsonString.contains("<div")))
427          if (!matches(actualJsonString, expectedJsonString))
428            if (!sameBytes(unBase64(actualJsonString), unBase64(expectedJsonString)))
429              return createNotEqualMessage(id, "string property values differ at " + path, expectedJsonString, actualJsonString);
430      } else if (actualJsonPrimitive.isJsonNumber() && expectedJsonPrimitive.isJsonNumber()) {
431        if (!actualJsonPrimitive.asString().equals(expectedJsonPrimitive.asString()))
432          return createNotEqualMessage(id, "number property values differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
433      } else if (expectedJsonElement instanceof JsonNull) {
434        return actualJsonPrimitive instanceof JsonNull ? null : createNotEqualMessage(id, "null Properties differ at " + path, "null", actualJsonPrimitive.asString());
435      } else {
436        return createNotEqualMessage(id, "property types differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
437      }
438    } else if (actualJsonElement instanceof JsonObject) {
439      String s = compareObjects(id, path, (JsonObject) expectedJsonElement, (JsonObject) actualJsonElement);
440      if (!Utilities.noString(s))
441        return s;
442    } else if (actualJsonElement instanceof JsonArray) {
443      JsonArray actualArray = (JsonArray) actualJsonElement;
444      JsonArray expectedArray = (JsonArray) expectedJsonElement;
445
446      int as = actualArray.size();
447      int es = expectedArray.size();
448      if (countOnly) {
449        if (as != es) {
450          return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
451        }
452      } else {
453        int expectedMin = countExpectedMin(expectedArray, name, parent);
454        int oc = optionalCount(expectedArray, name, parent);
455        
456
457        if (patternMode) {
458          int c = 0;
459          for (int i = 0; i < expectedArray.size(); i++) {
460            String s = "Doesn't exist";
461            CommaSeparatedStringBuilder cs = new CommaSeparatedStringBuilder("\r\n");
462            cs.append("");
463            while (s != null && c < actualArray.size()) {
464              s = compareNodes(id, path + "[" + Integer.toString(i) + "]", expectedArray.get(i), actualArray.get(c), false, null, null);
465              if (s != null) {
466                cs.append("  "+s);
467              }
468              c++;              
469            }
470            if (s != null) {              
471              return "The expected item at "+path+" at index "+i+" was not found: "+cs.toString();
472            }
473          }
474        } else {
475          if (as > es || as < expectedMin) {
476            return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
477          }
478          int c = 0;
479          for (int i = 0; i < es; i++) {
480            if (c >= as) {
481              if (i >= es - oc && isOptional(expectedArray.get(i), name, parent)) {
482                return null; // this is OK 
483              } else {
484                return "One or more array items did not match at "+path+" starting at index "+i;
485              }
486            }
487            String s = compareNodes(id, path + "[" + Integer.toString(i) + "]", expectedArray.get(i), actualArray.get(c), false, null, null);
488            if (!Utilities.noString(s) && !isOptional(expectedArray.get(i), name, parent)) {
489              return s;
490            }
491            if (Utilities.noString(s)) {
492              c++;
493            }
494          }
495          if (c < as) {
496            return "Unexpected Node found in array at '"+path+"' at index "+c;
497          }
498        }
499      }
500    } else
501      return "unhandled property " + actualJsonElement.getClass().getName();
502    return null;
503  }
504
505  private int optionalCount(JsonArray arr, String name, JsonObject parent) {
506    int c = 0;
507    for (JsonElement e : arr) {
508      if (e.isJsonObject()) {
509        JsonObject j = e.asJsonObject();
510        if (j.isJsonString("$optional$") && passesOptionalFilter(j.asString("$optional$"))) {
511          c++;
512        }
513        if (j.isJsonBoolean("$optional$") && j.asBoolean("$optional$")) {
514          c++;
515        }
516      }
517    }
518    return c;
519  }
520
521  private boolean isOptional(JsonElement e, String name, JsonObject parent) {
522    if (e.isJsonObject()) {
523      JsonObject j = e.asJsonObject();
524      if (j.isJsonString("$optional$") && passesOptionalFilter(j.asString("$optional$"))) {
525        return true;
526      } else if (j.isJsonBoolean("$optional$") && j.asBoolean("$optional$")) {
527        return true;
528      } else {
529        return false;
530      }
531    } else {
532      return false;
533    }
534  }
535
536  private boolean passesOptionalFilter(String token) {
537    if (token.startsWith("!")) {
538      return modes == null || !modes.contains(token.substring(1));
539    } else {
540      return modes != null && modes.contains(token);
541    }
542  }
543
544  private int countExpectedMin(JsonArray array, String name, JsonObject parent) {
545    int count = array.size();
546    for (JsonElement e : array) {
547      if (isOptional(e, name, parent)) {
548        count--;
549      }
550    }
551    return count;
552  }
553
554  private boolean matches(String actualJsonString, String expectedJsonString) {
555    if (expectedJsonString.startsWith("$") && expectedJsonString.endsWith("$")) {
556      if (expectedJsonString.startsWith("$choice:")) {
557        return Utilities.existsInList(actualJsonString, readChoices(expectedJsonString.substring(8, expectedJsonString.length()-1)));
558
559      } else if (expectedJsonString.startsWith("$fragments:")) {
560        List<String> fragments = readChoices(expectedJsonString.substring(11, expectedJsonString.length()-1));
561        for (String f : fragments) {
562          if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
563            return false;
564          }
565        }
566        return true;
567      } else if (expectedJsonString.startsWith("$external:")) {
568        String[] cmd = expectedJsonString.substring(1, expectedJsonString.length() - 1).split("\\:");
569        if (externals != null) {
570          String s = externals.asString(cmd[1]);
571          return actualJsonString.equals(s);
572        } else if (cmd.length <= 2) {
573          return true;
574        } else {
575          List<String> fragments = readChoices(cmd[2]);
576          for (String f : fragments) {
577            if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
578              return false;
579            }
580          }
581          return true;
582        }
583      } else {
584        switch (expectedJsonString) {
585        case "$$" : return true;
586        case "$instant$": return actualJsonString.matches("([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))");
587        case "$date$": return actualJsonString.matches("([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?");
588        case "$uuid$": return actualJsonString.matches("urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
589        case "$string$": return actualJsonString.equals(actualJsonString.trim());
590        case "$id$": return actualJsonString.matches("[A-Za-z0-9\\-\\.]{1,64}");
591        case "$url$": return actualJsonString.matches("(https?://|www\\.)[-a-zA-Z0-9+&@#/%?=~_|!:.;]*[-a-zA-Z0-9+&@#/%=~_|]");
592        case "$token$": return actualJsonString.matches("[0-9a-zA-Z_][0-9a-zA-Z_\\.\\-]*");
593        case "$semver$": return actualJsonString.matches("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$");
594        case "$version$": return matchesVariable(actualJsonString, "version");
595        default: 
596          throw new Error("Unhandled template: "+expectedJsonString);
597        }
598      }
599    } else {
600      return actualJsonString.equals(expectedJsonString);
601    }
602  }
603
604  private boolean matchesVariable(String value, String name) {
605    if (variables.containsKey(name)) {
606      return value.equals(variables.get(name));
607    } else {
608      return true;
609    }
610  }
611
612  private List<String> readChoices(String s) {
613    List<String> list = new ArrayList<>();
614    for (String p : s.split("\\|")) {
615      list.add(p);
616    }
617    return list;
618  }
619
620  public String checkTextIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
621    return checkTextIsSame(id, expected, actual, true);
622  }
623
624  public String checkTextIsSame(String id, String expectedString, String actualString, boolean showDiff) throws FileNotFoundException, IOException {
625    String result = compareText(id, expectedString, actualString);
626    if (result != null && SHOW_DIFF && showDiff) {
627      String diff = null;
628      if (System.getProperty("os.name").contains("Linux"))
629        diff = Utilities.path("/", "usr", "bin", "meld");
630      else {
631        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
632          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
633        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
634          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
635      }
636      if (diff == null || diff.isEmpty())
637        return result;
638
639      List<String> command = new ArrayList<String>();
640      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
641      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
642      FileUtilities.stringToFile(expectedString, expected);
643      FileUtilities.stringToFile(actualString, actual);
644      command.add(diff);
645      if (diff.toLowerCase().contains("meld"))
646        command.add("--newtab");
647      command.add(expected);
648      command.add(actual);
649
650      ProcessBuilder builder = new ProcessBuilder(command);
651      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
652      builder.start();
653
654    }
655    return result;
656  }
657
658
659  private String compareText(String id, String expectedString, String actualString) {
660    for (int i = 0; i < Integer.min(expectedString.length(), actualString.length()); i++) {
661      if (expectedString.charAt(i) != actualString.charAt(i))
662        return createNotEqualMessage(id, "Strings differ at character " + Integer.toString(i), charWithContext(expectedString, i), charWithContext(actualString, i));
663    }
664    if (expectedString.length() != actualString.length())
665      return createNotEqualMessage(id, "Strings differ in length but match to the end of the shortest.", Integer.toString(expectedString.length()), Integer.toString(actualString.length()));
666    return null;
667  }
668
669  private String charWithContext(String s, int i) {
670    String result = s.substring(i, i+1);
671    if (i > 7) {
672      i = i - 7;
673    }
674    int e = i + 20;
675    if (e > s.length()) {
676      e = s.length();
677    }
678    if (e > i+1) {
679      result = result + " with context '"+s.substring(i, e)+"'";
680    }
681    return result;
682  }
683
684}