Coverage for C:\leo.repo\leo-editor\leo\core\leoCompare.py: 9%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#@+leo-ver=5-thin
2#@+node:ekr.20180212072657.2: * @file leoCompare.py
3"""Leo's base compare class."""
4import difflib
5import filecmp
6import os
7from leo.core import leoGlobals as g
8#@+others
9#@+node:ekr.20031218072017.3633: ** class LeoCompare
10class BaseLeoCompare:
11 """The base class for Leo's compare code."""
12 #@+others
13 #@+node:ekr.20031218072017.3634: *3* compare.__init__
14 # All these ivars are known to the LeoComparePanel class.
16 def __init__(self,
17 # Keyword arguments are much convenient and more clear for scripts.
18 commands=None,
19 appendOutput=False,
20 ignoreBlankLines=True,
21 ignoreFirstLine1=False,
22 ignoreFirstLine2=False,
23 ignoreInteriorWhitespace=False,
24 ignoreLeadingWhitespace=True,
25 ignoreSentinelLines=False,
26 limitCount=0, # Zero means don't stop.
27 limitToExtension=".py", # For directory compares.
28 makeWhitespaceVisible=True,
29 printBothMatches=False,
30 printMatches=False,
31 printMismatches=True,
32 printTrailingMismatches=False,
33 outputFileName=None
34 ):
35 # It is more convenient for the LeoComparePanel to set these directly.
36 self.c = commands
37 self.appendOutput = appendOutput
38 self.ignoreBlankLines = ignoreBlankLines
39 self.ignoreFirstLine1 = ignoreFirstLine1
40 self.ignoreFirstLine2 = ignoreFirstLine2
41 self.ignoreInteriorWhitespace = ignoreInteriorWhitespace
42 self.ignoreLeadingWhitespace = ignoreLeadingWhitespace
43 self.ignoreSentinelLines = ignoreSentinelLines
44 self.limitCount = limitCount
45 self.limitToExtension = limitToExtension
46 self.makeWhitespaceVisible = makeWhitespaceVisible
47 self.printBothMatches = printBothMatches
48 self.printMatches = printMatches
49 self.printMismatches = printMismatches
50 self.printTrailingMismatches = printTrailingMismatches
51 # For communication between methods...
52 self.outputFileName = outputFileName
53 self.fileName1 = None
54 self.fileName2 = None
55 # Open files...
56 self.outputFile = None
57 #@+node:ekr.20031218072017.3635: *3* compare_directories (entry)
58 # We ignore the filename portion of path1 and path2 if it exists.
60 def compare_directories(self, path1, path2):
61 # Ignore everything except the directory name.
62 dir1 = g.os_path_dirname(path1)
63 dir2 = g.os_path_dirname(path2)
64 dir1 = g.os_path_normpath(dir1)
65 dir2 = g.os_path_normpath(dir2)
66 if dir1 == dir2:
67 return self.show("Please pick distinct directories.")
68 try:
69 list1 = os.listdir(dir1)
70 except Exception:
71 return self.show("invalid directory:" + dir1)
72 try:
73 list2 = os.listdir(dir2)
74 except Exception:
75 return self.show("invalid directory:" + dir2)
76 if self.outputFileName:
77 self.openOutputFile()
78 ok = self.outputFileName is None or self.outputFile
79 if not ok:
80 return None
81 # Create files and files2, the lists of files to be compared.
82 files1 = []
83 files2 = []
84 for f in list1:
85 junk, ext = g.os_path_splitext(f)
86 if self.limitToExtension:
87 if ext == self.limitToExtension:
88 files1.append(f)
89 else:
90 files1.append(f)
91 for f in list2:
92 junk, ext = g.os_path_splitext(f)
93 if self.limitToExtension:
94 if ext == self.limitToExtension:
95 files2.append(f)
96 else:
97 files2.append(f)
98 # Compare the files and set the yes, no and missing lists.
99 missing1, missing2, no, yes = [], [], [], []
100 for f1 in files1:
101 head, f2 = g.os_path_split(f1)
102 if f2 in files2:
103 try:
104 name1 = g.os_path_join(dir1, f1)
105 name2 = g.os_path_join(dir2, f2)
106 val = filecmp.cmp(name1, name2, 0)
107 if val:
108 yes.append(f1)
109 else:
110 no.append(f1)
111 except Exception:
112 self.show("exception in filecmp.cmp")
113 g.es_exception()
114 missing1.append(f1)
115 else:
116 missing1.append(f1)
117 for f2 in files2:
118 head, f1 = g.os_path_split(f2)
119 if f1 not in files1:
120 missing2.append(f1)
121 # Print the results.
122 for kind, files in (
123 ("----- matches --------", yes),
124 ("----- mismatches -----", no),
125 ("----- not found 1 ------", missing1),
126 ("----- not found 2 ------", missing2),
127 ):
128 self.show(kind)
129 for f in files:
130 self.show(f)
131 if self.outputFile:
132 self.outputFile.close()
133 self.outputFile = None
134 return None # To keep pychecker happy.
135 #@+node:ekr.20031218072017.3636: *3* compare_files (entry)
136 def compare_files(self, name1, name2):
137 if name1 == name2:
138 self.show("File names are identical.\nPlease pick distinct files.")
139 return
140 self.compare_two_files(name1, name2)
141 #@+node:ekr.20180211123531.1: *3* compare_list_of_files (entry for scripts)
142 def compare_list_of_files(self, aList1):
144 aList = list(set(aList1))
145 while len(aList) > 1:
146 path1 = aList[0]
147 for path2 in aList[1:]:
148 g.trace('COMPARE', path1, path2)
149 self.compare_two_files(path1, path2)
150 #@+node:ekr.20180211123741.1: *3* compare_two_files
151 def compare_two_files(self, name1, name2):
152 """A helper function."""
153 f1 = f2 = None
154 try:
155 f1 = self.doOpen(name1)
156 f2 = self.doOpen(name2)
157 if self.outputFileName:
158 self.openOutputFile()
159 ok = self.outputFileName is None or self.outputFile
160 ok = 1 if ok and ok != 0 else 0
161 if f1 and f2 and ok:
162 # Don't compare if there is an error opening the output file.
163 self.compare_open_files(f1, f2, name1, name2)
164 except Exception:
165 self.show("exception comparing files")
166 g.es_exception()
167 try:
168 if f1:
169 f1.close()
170 if f2:
171 f2.close()
172 if self.outputFile:
173 self.outputFile.close()
174 self.outputFile = None
175 except Exception:
176 self.show("exception closing files")
177 g.es_exception()
178 #@+node:ekr.20031218072017.3637: *3* compare_lines
179 def compare_lines(self, s1, s2):
180 if self.ignoreLeadingWhitespace:
181 s1 = s1.lstrip()
182 s2 = s2.lstrip()
183 if self.ignoreInteriorWhitespace:
184 k1 = g.skip_ws(s1, 0)
185 k2 = g.skip_ws(s2, 0)
186 ws1 = s1[:k1]
187 ws2 = s2[:k2]
188 tail1 = s1[k1:]
189 tail2 = s2[k2:]
190 tail1 = tail1.replace(" ", "").replace("\t", "")
191 tail2 = tail2.replace(" ", "").replace("\t", "")
192 s1 = ws1 + tail1
193 s2 = ws2 + tail2
194 return s1 == s2
195 #@+node:ekr.20031218072017.3638: *3* compare_open_files
196 def compare_open_files(self, f1, f2, name1, name2):
197 # self.show("compare_open_files")
198 lines1 = 0
199 lines2 = 0
200 mismatches = 0
201 printTrailing = True
202 sentinelComment1 = sentinelComment2 = None
203 if self.openOutputFile():
204 self.show("1: " + name1)
205 self.show("2: " + name2)
206 self.show("")
207 s1 = s2 = None
208 #@+<< handle opening lines >>
209 #@+node:ekr.20031218072017.3639: *4* << handle opening lines >>
210 if self.ignoreSentinelLines:
211 s1 = g.readlineForceUnixNewline(f1)
212 lines1 += 1
213 s2 = g.readlineForceUnixNewline(f2)
214 lines2 += 1
215 # Note: isLeoHeader may return None.
216 sentinelComment1 = self.isLeoHeader(s1)
217 sentinelComment2 = self.isLeoHeader(s2)
218 if not sentinelComment1:
219 self.show("no @+leo line for " + name1)
220 if not sentinelComment2:
221 self.show("no @+leo line for " + name2)
222 if self.ignoreFirstLine1:
223 if s1 is None:
224 g.readlineForceUnixNewline(f1)
225 lines1 += 1
226 s1 = None
227 if self.ignoreFirstLine2:
228 if s2 is None:
229 g.readlineForceUnixNewline(f2)
230 lines2 += 1
231 s2 = None
232 #@-<< handle opening lines >>
233 while 1:
234 if s1 is None:
235 s1 = g.readlineForceUnixNewline(f1)
236 lines1 += 1
237 if s2 is None:
238 s2 = g.readlineForceUnixNewline(f2)
239 lines2 += 1
240 #@+<< ignore blank lines and/or sentinels >>
241 #@+node:ekr.20031218072017.3640: *4* << ignore blank lines and/or sentinels >>
242 # Completely empty strings denotes end-of-file.
243 if s1:
244 if self.ignoreBlankLines and s1.isspace():
245 s1 = None
246 continue
247 if self.ignoreSentinelLines and sentinelComment1 and self.isSentinel(
248 s1, sentinelComment1):
249 s1 = None
250 continue
251 if s2:
252 if self.ignoreBlankLines and s2.isspace():
253 s2 = None
254 continue
255 if self.ignoreSentinelLines and sentinelComment2 and self.isSentinel(
256 s2, sentinelComment2):
257 s2 = None
258 continue
259 #@-<< ignore blank lines and/or sentinels >>
260 n1 = len(s1)
261 n2 = len(s2)
262 if n1 == 0 and n2 != 0:
263 self.show("1.eof***:")
264 if n2 == 0 and n1 != 0:
265 self.show("2.eof***:")
266 if n1 == 0 or n2 == 0:
267 break
268 match = self.compare_lines(s1, s2)
269 if not match:
270 mismatches += 1
271 #@+<< print matches and/or mismatches >>
272 #@+node:ekr.20031218072017.3641: *4* << print matches and/or mismatches >>
273 if self.limitCount == 0 or mismatches <= self.limitCount:
274 if match and self.printMatches:
275 if self.printBothMatches:
276 z1 = "1." + str(lines1)
277 z2 = "2." + str(lines2)
278 self.dump(z1.rjust(6) + ' :', s1)
279 self.dump(z2.rjust(6) + ' :', s2)
280 else:
281 self.dump(str(lines1).rjust(6) + ' :', s1)
282 if not match and self.printMismatches:
283 z1 = "1." + str(lines1)
284 z2 = "2." + str(lines2)
285 self.dump(z1.rjust(6) + '*:', s1)
286 self.dump(z2.rjust(6) + '*:', s2)
287 #@-<< print matches and/or mismatches >>
288 #@+<< warn if mismatch limit reached >>
289 #@+node:ekr.20031218072017.3642: *4* << warn if mismatch limit reached >>
290 if self.limitCount > 0 and mismatches >= self.limitCount:
291 if printTrailing:
292 self.show("")
293 self.show("limit count reached")
294 self.show("")
295 printTrailing = False
296 #@-<< warn if mismatch limit reached >>
297 s1 = s2 = None # force a read of both lines.
298 #@+<< handle reporting after at least one eof is seen >>
299 #@+node:ekr.20031218072017.3643: *4* << handle reporting after at least one eof is seen >>
300 if n1 > 0:
301 lines1 += self.dumpToEndOfFile("1.", f1, s1, lines1, printTrailing)
302 if n2 > 0:
303 lines2 += self.dumpToEndOfFile("2.", f2, s2, lines2, printTrailing)
304 self.show("")
305 self.show("lines1:" + str(lines1))
306 self.show("lines2:" + str(lines2))
307 self.show("mismatches:" + str(mismatches))
308 #@-<< handle reporting after at least one eof is seen >>
309 #@+node:ekr.20031218072017.3644: *3* compare.filecmp
310 def filecmp(self, f1, f2):
311 val = filecmp.cmp(f1, f2)
312 if val:
313 self.show("equal")
314 else:
315 self.show("*** not equal")
316 return val
317 #@+node:ekr.20031218072017.3645: *3* compare.utils...
318 #@+node:ekr.20031218072017.3646: *4* compare.doOpen
319 def doOpen(self, name):
320 try:
321 f = open(name, 'r')
322 return f
323 except Exception:
324 self.show("can not open:" + '"' + name + '"')
325 return None
326 #@+node:ekr.20031218072017.3647: *4* compare.dump
327 def dump(self, tag, s):
328 compare = self
329 out = tag
330 for ch in s[:-1]: # don't print the newline
331 if compare.makeWhitespaceVisible:
332 if ch == '\t':
333 out += "["
334 out += "t"
335 out += "]"
336 elif ch == ' ':
337 out += "["
338 out += " "
339 out += "]"
340 else:
341 out += ch
342 else:
343 out += ch
344 self.show(out)
345 #@+node:ekr.20031218072017.3648: *4* compare.dumpToEndOfFile
346 def dumpToEndOfFile(self, tag, f, s, line, printTrailing):
347 trailingLines = 0
348 while 1:
349 if not s:
350 s = g.readlineForceUnixNewline(f)
351 if not s:
352 break
353 trailingLines += 1
354 if self.printTrailingMismatches and printTrailing:
355 z = tag + str(line)
356 tag2 = z.rjust(6) + "+:"
357 self.dump(tag2, s)
358 s = None
359 self.show(tag + str(trailingLines) + " trailing lines")
360 return trailingLines
361 #@+node:ekr.20031218072017.3649: *4* compare.isLeoHeader & isSentinel
362 #@+at These methods are based on AtFile.scanHeader(). They are simpler
363 # because we only care about the starting sentinel comment: any line
364 # starting with the starting sentinel comment is presumed to be a
365 # sentinel line.
366 #@@c
368 def isLeoHeader(self, s):
369 tag = "@+leo"
370 j = s.find(tag)
371 if j > 0:
372 i = g.skip_ws(s, 0)
373 if i < j:
374 return s[i:j]
375 return None
377 def isSentinel(self, s, sentinelComment):
378 i = g.skip_ws(s, 0)
379 return g.match(s, i, sentinelComment)
380 #@+node:ekr.20031218072017.1144: *4* compare.openOutputFile
381 def openOutputFile(self):
382 if self.outputFileName is None:
383 return
384 theDir, name = g.os_path_split(self.outputFileName)
385 if not theDir:
386 self.show("empty output directory")
387 return
388 if not name:
389 self.show("empty output file name")
390 return
391 if not g.os_path_exists(theDir):
392 self.show("output directory not found: " + theDir)
393 else:
394 try:
395 if self.appendOutput:
396 self.show("appending to " + self.outputFileName)
397 self.outputFile = open(self.outputFileName, "ab")
398 else:
399 self.show("writing to " + self.outputFileName)
400 self.outputFile = open(self.outputFileName, "wb")
401 except Exception:
402 self.outputFile = None
403 self.show("exception opening output file")
404 g.es_exception()
405 #@+node:ekr.20031218072017.3650: *4* compare.show
406 def show(self, s):
407 # g.pr(s)
408 if self.outputFile:
409 # self.outputFile is opened in 'wb' mode.
410 s = g.toEncodedString(s + '\n')
411 self.outputFile.write(s)
412 elif self.c:
413 g.es(s)
414 else:
415 g.pr(s)
416 g.pr('')
417 #@+node:ekr.20031218072017.3651: *4* compare.showIvars
418 def showIvars(self):
419 self.show("fileName1:" + str(self.fileName1))
420 self.show("fileName2:" + str(self.fileName2))
421 self.show("outputFileName:" + str(self.outputFileName))
422 self.show("limitToExtension:" + str(self.limitToExtension))
423 self.show("")
424 self.show("ignoreBlankLines:" + str(self.ignoreBlankLines))
425 self.show("ignoreFirstLine1:" + str(self.ignoreFirstLine1))
426 self.show("ignoreFirstLine2:" + str(self.ignoreFirstLine2))
427 self.show("ignoreInteriorWhitespace:" + str(self.ignoreInteriorWhitespace))
428 self.show("ignoreLeadingWhitespace:" + str(self.ignoreLeadingWhitespace))
429 self.show("ignoreSentinelLines:" + str(self.ignoreSentinelLines))
430 self.show("")
431 self.show("limitCount:" + str(self.limitCount))
432 self.show("printMatches:" + str(self.printMatches))
433 self.show("printMismatches:" + str(self.printMismatches))
434 self.show("printTrailingMismatches:" + str(self.printTrailingMismatches))
435 #@-others
437class LeoCompare(BaseLeoCompare):
438 """
439 A class containing Leo's compare code.
441 These are not very useful comparisons.
442 """
443 pass
444#@+node:ekr.20180211170333.1: ** class CompareLeoOutlines
445class CompareLeoOutlines:
446 """
447 A class to do outline-oriented diffs of two or more .leo files.
448 Similar to GitDiffController, adapted for use by scripts.
449 """
451 def __init__(self, c):
452 """Ctor for the LeoOutlineCompare class."""
453 self.c = c
454 self.file_node = None
455 self.root = None
456 self.path1 = None
457 self.path2 = None
458 #@+others
459 #@+node:ekr.20180211170333.2: *3* loc.diff_list_of_files (entry)
460 def diff_list_of_files(self, aList, visible=True):
461 """The main entry point for scripts."""
462 if len(aList) < 2:
463 g.trace('Not enough files in', repr(aList))
464 return
465 self.root = self.create_root(aList)
466 self.visible = visible
467 while len(aList) > 1:
468 path1 = aList[0]
469 aList = aList[1:]
470 for path2 in aList:
471 self.diff_two_files(path1, path2)
472 self.finish()
473 #@+node:ekr.20180211170333.3: *3* loc.diff_two_files
474 def diff_two_files(self, fn1, fn2):
475 """Create an outline describing the git diffs for fn."""
476 self.path1, self.path2 = fn1, fn2
477 s1 = self.get_file(fn1)
478 s2 = self.get_file(fn2)
479 lines1 = g.splitLines(s1)
480 lines2 = g.splitLines(s2)
481 diff_list = list(difflib.unified_diff(lines1, lines2, fn1, fn2))
482 diff_list.insert(0, '@language patch\n')
483 self.file_node = self.create_file_node(diff_list, fn1, fn2)
484 # These will be left open
485 c1 = self.open_outline(fn1)
486 c2 = self.open_outline(fn2)
487 if c1 and c2:
488 self.make_diff_outlines(c1, c2)
489 self.file_node.b = (
490 f"{self.file_node.b.rstrip()}\n"
491 f"@language {c2.target_language}\n")
492 #@+node:ekr.20180211170333.4: *3* loc.Utils
493 #@+node:ekr.20180211170333.5: *4* loc.compute_dicts
494 def compute_dicts(self, c1, c2):
495 """Compute inserted, deleted, changed dictionaries."""
496 d1 = {v.fileIndex: v for v in c1.all_unique_nodes()}
497 d2 = {v.fileIndex: v for v in c2.all_unique_nodes()}
498 added = {key: d2.get(key) for key in d2 if not d1.get(key)}
499 deleted = {key: d1.get(key) for key in d1 if not d2.get(key)}
500 changed = {}
501 for key in d1:
502 if key in d2:
503 v1 = d1.get(key)
504 v2 = d2.get(key)
505 assert v1 and v2
506 assert v1.context != v2.context
507 if v1.h != v2.h or v1.b != v2.b:
508 changed[key] = (v1, v2)
509 return added, deleted, changed
510 #@+node:ekr.20180211170333.6: *4* loc.create_compare_node
511 def create_compare_node(self, c1, c2, d, kind):
512 """Create nodes describing the changes."""
513 if not d:
514 return
515 parent = self.file_node.insertAsLastChild()
516 parent.setHeadString(kind)
517 for key in d:
518 if kind.lower() == 'changed':
519 v1, v2 = d.get(key)
520 # Organizer node: contains diff
521 organizer = parent.insertAsLastChild()
522 organizer.h = v2.h
523 body = list(difflib.unified_diff(
524 g.splitLines(v1.b),
525 g.splitLines(v2.b),
526 self.path1,
527 self.path2,
528 ))
529 if ''.join(body).strip():
530 body.insert(0, '@language patch\n')
531 body.append(f"@language {c2.target_language}\n")
532 else:
533 body = ['Only headline has changed']
534 organizer.b = ''.join(body)
535 # Node 1:
536 p1 = organizer.insertAsLastChild()
537 p1.h = '1:' + v1.h
538 p1.b = v1.b
539 # Node 2:
540 assert v1.fileIndex == v2.fileIndex
541 p2 = organizer.insertAsLastChild()
542 p2.h = '2:' + v2.h
543 p2.b = v2.b
544 else:
545 v = d.get(key)
546 p = parent.insertAsLastChild()
547 p.h = v.h
548 p.b = v.b
549 #@+node:ekr.20180211170333.7: *4* loc.create_file_node
550 def create_file_node(self, diff_list, fn1, fn2):
551 """Create an organizer node for the file."""
552 p = self.root.insertAsLastChild()
553 p.h = f"{g.shortFileName(fn1).strip()}, {g.shortFileName(fn2).strip()}"
554 p.b = ''.join(diff_list)
555 return p
556 #@+node:ekr.20180211170333.8: *4* loc.create_root
557 def create_root(self, aList):
558 """Create the top-level organizer node describing all the diffs."""
559 c = self.c
560 p = c.lastTopLevel().insertAfter()
561 p.h = 'diff-leo-files'
562 p.b = '\n'.join(aList) + '\n'
563 return p
564 #@+node:ekr.20180211170333.10: *4* loc.finish
565 def finish(self):
566 """Finish execution of this command."""
567 c = self.c
568 if hasattr(g.app.gui, 'frameFactory'):
569 tff = g.app.gui.frameFactory
570 tff.setTabForCommander(c)
571 c.selectPosition(self.root)
572 self.root.expand()
573 c.bodyWantsFocus()
574 c.redraw()
575 #@+node:ekr.20180211170333.11: *4* loc.get_file
576 def get_file(self, path):
577 """Return the contents of the file whose path is given."""
578 with open(path, 'rb') as f:
579 s = f.read()
580 return g.toUnicode(s).replace('\r', '')
581 #@+node:ekr.20180211170333.13: *4* loc.make_diff_outlines
582 def make_diff_outlines(self, c1, c2):
583 """Create an outline-oriented diff from the outlines c1 and c2."""
584 added, deleted, changed = self.compute_dicts(c1, c2)
585 table = (
586 (added, 'Added'),
587 (deleted, 'Deleted'),
588 (changed, 'Changed'))
589 for d, kind in table:
590 self.create_compare_node(c1, c2, d, kind)
591 #@+node:ekr.20180211170333.14: *4* loc.open_outline
592 def open_outline(self, fn):
593 """
594 Find the commander for fn, creating a new outline tab if necessary.
596 Using open commanders works because we always read entire .leo files.
597 """
598 for frame in g.app.windowList:
599 if frame.c.fileName() == fn:
600 return frame.c
601 gui = None if self.visible else g.app.nullGui
602 return g.openWithFileName(fn, gui=gui)
603 #@-others
604#@+node:ekr.20180214041049.1: ** Top-level commands and helpers
605#@+node:ekr.20180213104556.1: *3* @g.command(diff-and-open-leo-files)
606@g.command('diff-and-open-leo-files')
607def diff_and_open_leo_files(event):
608 """
609 Open a dialog prompting for two or more .leo files.
611 Opens all the files and creates a top-level node in c's outline showing
612 the diffs of those files, two at a time.
613 """
614 diff_leo_files_helper(event,
615 title="Diff And Open Leo Files",
616 visible=True,
617 )
618#@+node:ekr.20180213040339.1: *3* @g.command(diff-leo-files)
619@g.command('diff-leo-files')
620def diff_leo_files(event):
621 """
622 Open a dialog prompting for two or more .leo files.
624 Creates a top-level node showing the diffs of those files, two at a time.
625 """
626 diff_leo_files_helper(event,
627 title="Diff Leo Files",
628 visible=False,
629 )
630#@+node:ekr.20160331191740.1: *3* @g.command(diff-marked-nodes)
631@g.command('diff-marked-nodes')
632def diffMarkedNodes(event):
633 """
634 When two or more nodes are marked, this command does the following:
636 - Creates a "diff marked node" as the last top-level node. The body of
637 this node contains "diff n" nodes, one for each pair of compared
638 nodes.
640 - Each diff n contains the diffs between the two diffed nodes, that is,
641 difflib.Differ().compare(p1.b, p2.b). The children of the diff n are
642 *clones* of the two compared nodes.
643 """
644 c = event and event.get('c')
645 if not c:
646 return
647 aList = [z for z in c.all_unique_positions() if z.isMarked()]
648 n = 0
649 if len(aList) >= 2:
650 root = c.lastTopLevel().insertAfter()
651 root.h = 'diff marked nodes'
652 root.b = '\n'.join([z.h for z in aList]) + '\n'
653 while len(aList) > 1:
654 n += 1
655 p1, p2 = aList[0], aList[1]
656 aList = aList[1:]
657 lines = difflib.Differ().compare(
658 g.splitLines(p1.b.rstrip() + '\n'),
659 g.splitLines(p2.b.rstrip() + '\n'))
660 p = root.insertAsLastChild()
661 # p.h = 'Compare: %s, %s' % (g.truncate(p1.h, 22), g.truncate(p2.h, 22))
662 p.h = f"diff {n}"
663 p.b = f"1: {p1.h}\n2: {p2.h}\n{''.join(list(lines))}"
664 for p3 in (p1, p2):
665 clone = p3.clone()
666 clone.moveToLastChildOf(p)
667 root.expand()
668 # c.unmarkAll()
669 c.selectPosition(root)
670 c.redraw()
671 else:
672 g.es_print('Please mark at least 2 nodes')
673#@+node:ekr.20180213104627.1: *3* diff_leo_files_helper
674def diff_leo_files_helper(event, title, visible):
675 """Prompt for a list of .leo files to open."""
676 c = event and event.get('c')
677 if not c:
678 return
679 types = [
680 ("Leo files", "*.leo"),
681 ("All files", "*"),
682 ]
683 paths = g.app.gui.runOpenFileDialog(c,
684 title=title,
685 filetypes=types,
686 defaultextension=".leo",
687 multiple=True,
688 )
689 c.bringToFront()
690 # paths = [z for z in paths if g.os_path_exists(z)]
691 if len(paths) > 1:
692 CompareLeoOutlines(c).diff_list_of_files(paths, visible=visible)
693 elif len(paths) == 1:
694 g.es_print('Please pick two or more .leo files')
695#@-others
696#@@language python
697#@@tabwidth -4
698#@@pagewidth 70
699#@-leo