Skip to content

Method: commonPrefix(String, String)

1: /*
2: * *************************************************************************************************************************************************************
3: *
4: * TheseFoolishThings: Miscellaneous utilities
5: * http://tidalwave.it/projects/thesefoolishthings
6: *
7: * Copyright (C) 2009 - 2024 by Tidalwave s.a.s. (http://tidalwave.it)
8: *
9: * *************************************************************************************************************************************************************
10: *
11: * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
12: * You may obtain a copy of the License at
13: *
14: * http://www.apache.org/licenses/LICENSE-2.0
15: *
16: * 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
17: * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
18: *
19: * *************************************************************************************************************************************************************
20: *
21: * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src
22: * git clone https://github.com/tidalwave-it/thesefoolishthings-src
23: *
24: * *************************************************************************************************************************************************************
25: */
26: package it.tidalwave.util.test;
27:
28: import javax.annotation.Nonnegative;
29: import javax.annotation.Nonnull;
30: import javax.annotation.Nullable;
31: import java.util.ArrayList;
32: import java.util.List;
33: import java.io.BufferedReader;
34: import java.io.ByteArrayInputStream;
35: import java.io.File;
36: import java.io.IOException;
37: import java.io.InputStream;
38: import java.io.InputStreamReader;
39: import java.nio.file.Files;
40: import java.nio.file.Path;
41: import com.github.difflib.DiffUtils;
42: import com.github.difflib.patch.AbstractDelta;
43: import com.github.difflib.text.DiffRowGenerator;
44: import it.tidalwave.util.Pair;
45: import lombok.experimental.UtilityClass;
46: import lombok.extern.slf4j.Slf4j;
47: import static java.util.stream.Collectors.*;
48: import static java.nio.charset.StandardCharsets.UTF_8;
49: import static it.tidalwave.util.Pair.indexedPairStream;
50:
51: /***************************************************************************************************************************************************************
52: *
53: * A utility class to compare two text files and assert that they have the same contents.
54: *
55: * @author Fabrizio Giudici
56: *
57: **************************************************************************************************************************************************************/
58: @UtilityClass @Slf4j
59: public class FileComparisonUtils
60: {
61: private static final String P_BASE_NAME = FileComparisonUtils.class.getName();
62:
63: public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
64: public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";
65:
66: private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
67: private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
68: private static final String TF = "TEST FAILED";
69:
70: /***********************************************************************************************************************************************************
71: * Asserts that two files have the same contents.
72: *
73: * @param expectedFile the file with the expected contents
74: * @param actualFile the file with the contents to probe
75: * @throws IOException in case of error
76: **********************************************************************************************************************************************************/
77: public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
78: throws IOException
79: {
80: assertSameContents(expectedFile.toPath(), actualFile.toPath());
81: }
82:
83: /***********************************************************************************************************************************************************
84: * Asserts that two files have the same contents.
85: *
86: * @param expectedPath the file with the expected contents
87: * @param actualPath the file with the contents to probe
88: * @throws IOException in case of error
89: **********************************************************************************************************************************************************/
90: public static void assertSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
91: throws IOException
92: {
93: log.info("******** Comparing files:");
94: logPaths(expectedPath, actualPath, "");
95: assertSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath);
96: }
97:
98: /***********************************************************************************************************************************************************
99: * Asserts that two collections of strings have the same contents.
100: *
101: * @param expected the expected values
102: * @param actual the actual values
103: **********************************************************************************************************************************************************/
104: public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
105: {
106: assertSameContents(expected, actual, null, null);
107: }
108:
109: /***********************************************************************************************************************************************************
110: * Checks whether two files have the same contents.
111: *
112: * @param expectedPath the file with the expected contents
113: * @param actualPath the file with the contents to probe
114: * @return whether the two files have the same contents
115: * @throws IOException in case of error
116: * @since 1.2-ALPHA-15
117: **********************************************************************************************************************************************************/
118: public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
119: throws IOException
120: {
121: return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
122: .isEmpty();
123: }
124:
125: /***********************************************************************************************************************************************************
126: * Converts a string which contains newlines into a list of strings.
127: *
128: * @param string the source
129: * @return the strings
130: * @throws IOException in case of error
131: **********************************************************************************************************************************************************/
132: @Nonnull
133: public static List<String> stringToStrings (@Nonnull final String string)
134: throws IOException
135: {
136: //return List.of(string.split("\n"));
137: return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
138: }
139:
140: /***********************************************************************************************************************************************************
141: * Reads a file into a list of strings.
142: *
143: * @param file the file
144: * @return the strings
145: * @throws IOException in case of error
146: **********************************************************************************************************************************************************/
147: @Nonnull
148: public static List<String> fileToStrings (@Nonnull final Path file)
149: throws IOException
150: {
151: return Files.readAllLines(file);
152: }
153:
154: /***********************************************************************************************************************************************************
155: * Reads a classpath resource (not a regular file) into a list of strings.
156: *
157: * @param path the path of the classpath resource
158: * @return the strings
159: * @throws IOException in case of error
160: **********************************************************************************************************************************************************/
161: @Nonnull
162: public static List<String> resourceToStrings (@Nonnull final String path)
163: throws IOException
164: {
165: final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);
166:
167: if (is == null)
168: {
169: throw new RuntimeException("Resource not found: " + path);
170: }
171:
172: return resourceToStrings(is);
173: }
174:
175: /***********************************************************************************************************************************************************
176: * Reads an input stream into a list of strings. The stream is closed at the end.
177: *
178: * @param is the input stream
179: * @return the strings
180: * @throws IOException in case of error
181: **********************************************************************************************************************************************************/
182: @Nonnull
183: public static List<String> resourceToStrings (@Nonnull final InputStream is)
184: throws IOException
185: {
186: try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
187: {
188: final var result = new ArrayList<String>();
189:
190: for (;;)
191: {
192: final var s = br.readLine();
193:
194: if (s == null)
195: {
196: break;
197: }
198:
199: result.add(s);
200: }
201:
202: return result;
203: }
204: }
205:
206: /***********************************************************************************************************************************************************
207: * Given a string that represents a path whose segments are separated by the standard separator of the platform,
208: * returns the common prefix - which means the common directory parents.
209: *
210: * @param s1 the former string
211: * @param s2 the latter string
212: * @return the common prefix
213: **********************************************************************************************************************************************************/
214: @Nonnull
215: public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
216: {
217: final var min = Math.min(s1.length(), s2.length());
218: var latestSeenSlash = 0;
219:
220:• for (var i = 0; i < min; i++)
221: {
222:• if (s1.charAt(i) != s2.charAt(i))
223: {
224:• return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
225: }
226: else
227: {
228:• if (s1.charAt(i) == File.separatorChar)
229: {
230: latestSeenSlash = i;
231: }
232: }
233: }
234:
235: return s1.substring(0, min);
236: }
237:
238: /***********************************************************************************************************************************************************
239: * Asserts that two collections of strings have the same contents.
240: *
241: * @param expected the expected values
242: * @param actual the actual values
243: * @param expectedPath an optional path for expected values
244: * @param actualPath an optional path for actual values
245: **********************************************************************************************************************************************************/
246: private static void assertSameContents (@Nonnull final List<String> expected,
247: @Nonnull final List<String> actual,
248: @Nullable final Path expectedPath,
249: @Nullable final Path actualPath)
250: {
251: final var diff = checkSameContents(expected, actual, expectedPath, actualPath);
252:
253: if (!diff.isEmpty())
254: {
255: throw new AssertionError(String.join(System.lineSeparator(), diff));
256: }
257: }
258:
259: /***********************************************************************************************************************************************************
260: * Checks whether two collections of strings have the same contents.
261: *
262: * @param expected the expected values
263: * @param actual the actual values
264: * @param expectedPath an optional path for expected values
265: * @param actualPath an optional path for actual values
266: * @return the differences
267: **********************************************************************************************************************************************************/
268: private static List<String> checkSameContents (@Nonnull final List<String> expected,
269: @Nonnull final List<String> actual,
270: @Nullable final Path expectedPath,
271: @Nullable final Path actualPath)
272: {
273: final var deltas = DiffUtils.diff(expected, actual).getDeltas();
274:
275: if (deltas.isEmpty())
276: {
277: return List.of();
278: }
279:
280: if ((expectedPath != null) && (actualPath != null))
281: {
282: logPaths(expectedPath, actualPath, "TEST FAILED ");
283: }
284:
285: final var strings = toStrings(deltas);
286: strings.forEach(log::error);
287:
288: if (!TABULAR_OUTPUT)
289: {
290: log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
291: TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
292: }
293: else
294: {
295: final var generator = DiffRowGenerator.create()
296: .showInlineDiffs(false)
297: .inlineDiffByWord(true)
298: .lineNormalizer(l -> l)
299: .build();
300: final var pairs = generator.generateDiffRows(expected, actual)
301: .stream()
302: .filter(row -> !row.getNewLine().equals(row.getOldLine()))
303: .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
304: .limit(TABULAR_LIMIT)
305: .collect(toList());
306:
307: final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().orElseThrow();
308: final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().orElseThrow();
309: log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
310: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
311: log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual ", padB, ' '));
312: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
313: pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
314: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
315: }
316:
317: strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
318: return strings;
319: }
320:
321: /***********************************************************************************************************************************************************
322: * Converts deltas to output as a list of strings.
323: *
324: * @param deltas the deltas
325: * @return the strings
326: **********************************************************************************************************************************************************/
327: @Nonnull
328: private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
329: {
330: final List<String> strings = new ArrayList<>();
331:
332: deltas.forEach(delta ->
333: {
334: final var sourceLines = delta.getSource().getLines();
335: final var targetLines = delta.getTarget().getLines();
336: final var sourcePosition = delta.getSource().getPosition() + 1;
337: final var targetPosition = delta.getTarget().getPosition() + 1;
338:
339: switch (delta.getType())
340: {
341: case CHANGE:
342: indexedPairStream(sourceLines).forEach(p -> strings.add(
343: String.format("%s exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
344: indexedPairStream(targetLines).forEach(p -> strings.add(
345: String.format("%s act[%d] *%s*", TF, targetPosition + p.a, p.b)));
346: break;
347:
348: case DELETE:
349: indexedPairStream(sourceLines).forEach(p -> strings.add(
350: String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
351: break;
352:
353: case INSERT:
354: indexedPairStream(targetLines).forEach(p -> strings.add(
355: String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
356: break;
357:
358: default:
359: }
360: });
361:
362: return strings;
363: }
364:
365: /***********************************************************************************************************************************************************
366: * Logs info about file comparison paths.
367: *
368: * @param expectedPath the expected path
369: * @param actualPath the actual path
370: * @param prefix a log prefix
371: **********************************************************************************************************************************************************/
372: private static void logPaths (@Nonnull final Path expectedPath,
373: @Nonnull final Path actualPath,
374: @Nonnull final String prefix)
375: {
376: final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
377: final var actualPathAsString = actualPath.toAbsolutePath().toString();
378: final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
379: log.info("{}>>>> path is: {}", prefix, commonPath);
380: log.info("{}>>>> exp is: {}", prefix, expectedPathAsString.substring(commonPath.length()));
381: log.info("{}>>>> act is: {}", prefix, actualPathAsString.substring(commonPath.length()));
382: }
383:
384: /***********************************************************************************************************************************************************
385: * Pads a string to left to fit the given width.
386: *
387: * @param string the string
388: * @param width the width
389: * @return the padded string
390: **********************************************************************************************************************************************************/
391: @Nonnull
392: private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
393: {
394: return String.format("%-" + width + "s", string).replace(' ', padding);
395: }
396: }