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