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  public 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    for (int i = 0; i < actual.getLength(); i++) {
235      Node actualNode = actual.item(i);
236      String actualNodeName = actualNode.getNodeName();
237      if (!(actualNodeName.equals("xmlns") || actualNodeName.startsWith("xmlns:"))) {
238        Node expectedNode = expected.getNamedItem(actualNodeName);
239        if (expectedNode == null) {
240          return "Attributes differ at " + path + ": unexpected attribute " + actualNodeName;
241        }
242      }
243    }
244    return null;
245  }
246
247  private boolean sameBytes(byte[] b1, byte[] b2) {
248    if (b1.length == 0 || b2.length == 0)
249      return false;
250    if (b1.length != b2.length)
251      return false;
252    for (int i = 0; i < b1.length; i++)
253      if (b1[i] != b2[i])
254        return false;
255    return true;
256  }
257
258  private byte[] unBase64(String text) {
259    return Base64.decodeBase64(text);
260  }
261
262  private Node skipBlankText(Node node) {
263    while (node != null && (((node.getNodeType() == Node.TEXT_NODE) && StringUtils.isWhitespace(node.getTextContent())) || (node.getNodeType() == Node.COMMENT_NODE)))
264      node = node.getNextSibling();
265    return node;
266  }
267
268  private Document loadXml(String fn) throws Exception {
269    return loadXml(ManagedFileAccess.inStream(fn));
270  }
271
272  private Document loadXml(InputStream fn) throws Exception {
273    DocumentBuilderFactory factory = XMLUtil.newXXEProtectedDocumentBuilderFactory();
274    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
275    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
276    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
277    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
278    factory.setXIncludeAware(false);
279    factory.setExpandEntityReferences(false);
280
281    factory.setNamespaceAware(true);
282    DocumentBuilder builder = factory.newDocumentBuilder();
283    return builder.parse(fn);
284  }
285
286  public String checkJsonSrcIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
287    return checkJsonSrcIsSame(id, expected, actual, true);
288  }
289
290  public String checkJsonSrcIsSame(String id, String expectedString, String actualString, boolean showDiff) throws FileNotFoundException, IOException {
291    String result = compareJsonSrc(id, expectedString, actualString);
292    if (result != null && SHOW_DIFF && showDiff) {
293      String diff = null;
294      if (System.getProperty("os.name").contains("Linux"))
295        diff = Utilities.path("/", "usr", "bin", "meld");
296      else if (System.getenv("ProgramFiles(X86)") != null) {
297        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
298          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
299        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
300          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
301      }
302      if (diff == null || diff.isEmpty())
303        return result;
304
305      List<String> command = new ArrayList<String>();
306      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
307      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
308      FileUtilities.stringToFile(expectedString, expected);
309      FileUtilities.stringToFile(actualString, actual);
310      command.add(diff);
311      if (diff.toLowerCase().contains("meld"))
312        command.add("--newtab");
313      command.add(expected);
314      command.add(actual);
315
316      ProcessBuilder builder = new ProcessBuilder(command);
317      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
318      builder.start();
319
320    }
321    return result;
322  }
323
324  public String checkJsonIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
325    String result = compareJson(id, expected, actual);
326    if (result != null && SHOW_DIFF) {
327      String diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
328      List<String> command = new ArrayList<String>();
329      command.add("\"" + diff + "\" \"" + expected +  "\" \"" + actual + "\"");
330
331      ProcessBuilder builder = new ProcessBuilder(command);
332      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
333      builder.start();
334
335    }
336    return result;
337  }
338
339  private String compareJsonSrc(String id, String expected, String actual) throws FileNotFoundException, IOException {
340    JsonObject actualJsonObject = JsonParser.parseObject(actual);
341    JsonObject expectedJsonObject = JsonParser.parseObject(expected);
342    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
343  }
344
345  private String compareJson(String id, String expected, String actual) throws FileNotFoundException, IOException {
346    JsonObject actualJsonObject = JsonParser.parseObject(FileUtilities.fileToString(actual));
347    JsonObject expectedJsonObject = JsonParser.parseObject(FileUtilities.fileToString(expected));
348    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
349  }
350
351  public String compareObjects(String id, String path, JsonObject expectedJsonObject, JsonObject actualJsonObject) {
352    List<String> optionals = listOptionals(expectedJsonObject);
353    List<String> countOnlys = listCountOnlys(expectedJsonObject);
354    for (JsonProperty en : actualJsonObject.getProperties()) {
355      String n = en.getName();
356      if (!n.equals("fhir_comments")) {
357        if (expectedJsonObject.has(n)) {
358          String s = compareNodes(id, path + '.' + n, expectedJsonObject.get(n), en.getValue(), countOnlys.contains(n), n, actualJsonObject);
359          if (!Utilities.noString(s))
360            return s;
361        } else if (!patternMode) {
362          return "properties differ at " + path + ": unexpected property " + n;
363        }
364      }
365    }
366    for (JsonProperty en : expectedJsonObject.getProperties()) {
367      String n = en.getName();
368      if (!n.equals("fhir_comments") && !isOptional(n, optionals)) {
369        if (!actualJsonObject.has(n) && !allOptional(en.getValue()))
370          return "properties differ at " + path + ": missing property " + n;
371      }
372    }
373    return null;
374  }
375
376  private boolean isOptional(String n, List<String> optionals) {
377    return n.equals("$optional$") || optionals.contains("*")  || optionals.contains(n);
378  }
379
380  private boolean allOptional(JsonElement value) {
381    if (value.isJsonArray()) {
382      JsonArray a = value.asJsonArray();
383      for (JsonElement e : a) {
384        if (e.isJsonObject()) {
385          JsonObject o = e.asJsonObject();
386          if (!o.has("$optional$")) {
387            return false;
388          }
389        } else {
390          // nothing
391        }
392      }
393      return true;
394    } else {
395      return false;
396    }
397  }
398
399  private List<String> listOptionals(JsonObject expectedJsonObject) {
400    List<String> res = new ArrayList<>();
401    if (expectedJsonObject.has("$optional-properties$")) {
402      res.add("$optional-properties$");
403      res.add("$count-arrays$");
404      for (String s : expectedJsonObject.getStrings("$optional-properties$")) {
405        res.add(s);
406      }
407    }
408    return res;
409  }
410
411  private List<String> listCountOnlys(JsonObject expectedJsonObject) {
412    List<String> res = new ArrayList<>();
413    if (expectedJsonObject.has("$count-arrays$")) {
414      for (String s : expectedJsonObject.getStrings("$count-arrays$")) {
415        res.add(s);
416      }
417    }
418    return res;
419  }
420
421  private String compareNodes(String id, String path, JsonElement expectedJsonElement, JsonElement actualJsonElement, boolean countOnly, String name, JsonObject parent) {
422    if (!(expectedJsonElement instanceof JsonPrimitive && actualJsonElement instanceof JsonPrimitive)) {
423      if (actualJsonElement.getClass() != expectedJsonElement.getClass()) {
424        return createNotEqualMessage(id, "properties differ at " + path, expectedJsonElement.getClass().getName(), actualJsonElement.getClass().getName());
425      }
426    }
427    if (actualJsonElement instanceof JsonPrimitive) {
428      JsonPrimitive actualJsonPrimitive = (JsonPrimitive) actualJsonElement;
429      JsonPrimitive expectedJsonPrimitive = (JsonPrimitive) expectedJsonElement;
430      if (actualJsonPrimitive.isJsonBoolean() && expectedJsonPrimitive.isJsonBoolean()) {
431        if (actualJsonPrimitive.asBoolean() != expectedJsonPrimitive.asBoolean())
432          return createNotEqualMessage(id, "boolean property values differ at " + path , expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
433      } else if (actualJsonPrimitive.isJsonString() && expectedJsonPrimitive.isJsonString()) {
434        String actualJsonString = actualJsonPrimitive.asString();
435        String expectedJsonString = expectedJsonPrimitive.asString();
436        if (!(actualJsonString.contains("<div") && expectedJsonString.contains("<div")))
437          if (!matches(actualJsonString, expectedJsonString))
438            if (!sameBytes(unBase64(actualJsonString), unBase64(expectedJsonString)))
439              return createNotEqualMessage(id, "string property values differ at " + path, expectedJsonString, actualJsonString);
440      } else if (actualJsonPrimitive.isJsonNumber() && expectedJsonPrimitive.isJsonNumber()) {
441        if (!actualJsonPrimitive.asString().equals(expectedJsonPrimitive.asString()))
442          return createNotEqualMessage(id, "number property values differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
443      } else if (expectedJsonElement instanceof JsonNull) {
444        return actualJsonPrimitive instanceof JsonNull ? null : createNotEqualMessage(id, "null Properties differ at " + path, "null", actualJsonPrimitive.asString());
445      } else {
446        return createNotEqualMessage(id, "property types differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
447      }
448    } else if (actualJsonElement instanceof JsonObject) {
449      String s = compareObjects(id, path, (JsonObject) expectedJsonElement, (JsonObject) actualJsonElement);
450      if (!Utilities.noString(s))
451        return s;
452    } else if (actualJsonElement instanceof JsonArray) {
453      JsonArray actualArray = (JsonArray) actualJsonElement;
454      JsonArray expectedArray = (JsonArray) expectedJsonElement;
455
456      int as = actualArray.size();
457      int es = expectedArray.size();
458      if (countOnly) {
459        if (as != es) {
460          return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
461        }
462      } else {
463        int expectedMin = countExpectedMin(expectedArray, name, parent);
464        int oc = optionalCount(expectedArray, name, parent);
465        
466
467        if (patternMode) {
468          int c = 0;
469          for (int i = 0; i < expectedArray.size(); i++) {
470            String s = "Doesn't exist";
471            CommaSeparatedStringBuilder cs = new CommaSeparatedStringBuilder("\r\n");
472            cs.append("");
473            while (s != null && c < actualArray.size()) {
474              s = compareNodes(id, path + "[" + Integer.toString(i) + "]", expectedArray.get(i), actualArray.get(c), false, null, null);
475              if (s != null) {
476                cs.append("  "+s);
477              }
478              c++;              
479            }
480            if (s != null) {              
481              return "The expected item at "+path+" at index "+i+" was not found: "+cs.toString();
482            }
483          }
484        } else {
485          if (as > es || as < expectedMin) {
486            return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
487          }
488          int c = 0;
489          for (int i = 0; i < es; i++) {
490            if (c >= as) {
491              if (i >= es - oc && isOptional(expectedArray.get(i), name, parent)) {
492                return null; // this is OK 
493              } else {
494                return "One or more array items did not match at "+path+" starting at index "+i;
495              }
496            }
497            String s = compareNodes(id, path + "[" + Integer.toString(i) + "]", expectedArray.get(i), actualArray.get(c), false, null, null);
498            if (!Utilities.noString(s) && !isOptional(expectedArray.get(i), name, parent)) {
499              return s;
500            }
501            if (Utilities.noString(s)) {
502              c++;
503            }
504          }
505          if (c < as) {
506            return "Unexpected Node found in array at '"+path+"' at index "+c;
507          }
508        }
509      }
510    } else
511      return "unhandled property " + actualJsonElement.getClass().getName();
512    return null;
513  }
514
515  private int optionalCount(JsonArray arr, String name, JsonObject parent) {
516    int c = 0;
517    for (JsonElement e : arr) {
518      if (e.isJsonObject()) {
519        JsonObject j = e.asJsonObject();
520        if (j.isJsonString("$optional$") && passesOptionalFilter(j.asString("$optional$"))) {
521          c++;
522        }
523        if (j.isJsonBoolean("$optional$") && j.asBoolean("$optional$")) {
524          c++;
525        }
526      }
527    }
528    return c;
529  }
530
531  private boolean isOptional(JsonElement e, String name, JsonObject parent) {
532    if (e.isJsonObject()) {
533      JsonObject j = e.asJsonObject();
534      if (j.isJsonString("$optional$") && passesOptionalFilter(j.asString("$optional$"))) {
535        return true;
536      } else if (j.isJsonBoolean("$optional$") && j.asBoolean("$optional$")) {
537        return true;
538      } else {
539        return false;
540      }
541    } else {
542      return false;
543    }
544  }
545
546  private boolean passesOptionalFilter(String token) {
547    if (token.startsWith("!")) {
548      return modes == null || !modes.contains(token.substring(1));
549    } else {
550      return modes != null && modes.contains(token);
551    }
552  }
553
554  private int countExpectedMin(JsonArray array, String name, JsonObject parent) {
555    int count = array.size();
556    for (JsonElement e : array) {
557      if (isOptional(e, name, parent)) {
558        count--;
559      }
560    }
561    return count;
562  }
563
564  private boolean matches(String actualJsonString, String expectedJsonString) {
565    if (expectedJsonString.startsWith("$") && expectedJsonString.endsWith("$")) {
566      if (expectedJsonString.startsWith("$choice:")) {
567        return Utilities.existsInList(actualJsonString, readChoices(expectedJsonString.substring(8, expectedJsonString.length()-1)));
568
569      } else if (expectedJsonString.startsWith("$fragments:")) {
570        List<String> fragments = readChoices(expectedJsonString.substring(11, expectedJsonString.length()-1));
571        for (String f : fragments) {
572          if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
573            return false;
574          }
575        }
576        return true;
577      } else if (expectedJsonString.startsWith("$external:")) {
578        String[] cmd = expectedJsonString.substring(1, expectedJsonString.length() - 1).split("\\:");
579        if (externals != null) {
580          String s = externals.asString(cmd[1]);
581          return actualJsonString.equals(s);
582        } else if (cmd.length <= 2) {
583          return true;
584        } else {
585          List<String> fragments = readChoices(cmd[2]);
586          for (String f : fragments) {
587            if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
588              return false;
589            }
590          }
591          return true;
592        }
593      } else {
594        switch (expectedJsonString) {
595        case "$$" : return true;
596        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))");
597        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)))?");
598        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}");
599        case "$string$": return actualJsonString.equals(actualJsonString.trim());
600        case "$id$": return actualJsonString.matches("[A-Za-z0-9\\-\\.]{1,64}");
601        case "$url$": return actualJsonString.matches("(https?://|www\\.)[-a-zA-Z0-9+&@#/%?=~_|!:.;]*[-a-zA-Z0-9+&@#/%=~_|]");
602        case "$token$": return actualJsonString.matches("[0-9a-zA-Z_][0-9a-zA-Z_\\.\\-]*");
603        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-]+)*))?$");
604        case "$version$": return matchesVariable(actualJsonString, "version");
605        default: 
606          throw new Error("Unhandled template: "+expectedJsonString);
607        }
608      }
609    } else {
610      return actualJsonString.equals(expectedJsonString);
611    }
612  }
613
614  private boolean matchesVariable(String value, String name) {
615    if (variables.containsKey(name)) {
616      return value.equals(variables.get(name));
617    } else {
618      return true;
619    }
620  }
621
622  private List<String> readChoices(String s) {
623    List<String> list = new ArrayList<>();
624    for (String p : s.split("\\|")) {
625      list.add(p);
626    }
627    return list;
628  }
629
630  public String checkTextIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
631    return checkTextIsSame(id, expected, actual, true);
632  }
633
634  public String checkTextIsSame(String id, String expectedString, String actualString, boolean showDiff) throws FileNotFoundException, IOException {
635    String result = compareText(id, expectedString, actualString);
636    if (result != null && SHOW_DIFF && showDiff) {
637      String diff = null;
638      if (System.getProperty("os.name").contains("Linux"))
639        diff = Utilities.path("/", "usr", "bin", "meld");
640      else {
641        if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
642          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
643        else if (FileUtilities.checkFileExists("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
644          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
645      }
646      if (diff == null || diff.isEmpty())
647        return result;
648
649      List<String> command = new ArrayList<String>();
650      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
651      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
652      FileUtilities.stringToFile(expectedString, expected);
653      FileUtilities.stringToFile(actualString, actual);
654      command.add(diff);
655      if (diff.toLowerCase().contains("meld"))
656        command.add("--newtab");
657      command.add(expected);
658      command.add(actual);
659
660      ProcessBuilder builder = new ProcessBuilder(command);
661      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
662      builder.start();
663
664    }
665    return result;
666  }
667
668
669  private String compareText(String id, String expectedString, String actualString) {
670    for (int i = 0; i < Integer.min(expectedString.length(), actualString.length()); i++) {
671      if (expectedString.charAt(i) != actualString.charAt(i))
672        return createNotEqualMessage(id, "Strings differ at character " + Integer.toString(i), charWithContext(expectedString, i), charWithContext(actualString, i));
673    }
674    if (expectedString.length() != actualString.length())
675      return createNotEqualMessage(id, "Strings differ in length but match to the end of the shortest.", Integer.toString(expectedString.length()), Integer.toString(actualString.length()));
676    return null;
677  }
678
679  private String charWithContext(String s, int i) {
680    String result = s.substring(i, i+1);
681    if (i > 7) {
682      i = i - 7;
683    }
684    int e = i + 20;
685    if (e > s.length()) {
686      e = s.length();
687    }
688    if (e > i+1) {
689      result = result + " with context '"+s.substring(i, e)+"'";
690    }
691    return result;
692  }
693
694}