Coverage for C:\leo.repo\leo-editor\leo\commands\editFileCommands.py : 13%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2#@+leo-ver=5-thin
3#@+node:ekr.20150514041209.1: * @file ../commands/editFileCommands.py
4#@@first
5"""Leo's file-editing commands."""
6#@+<< imports >>
7#@+node:ekr.20170806094317.4: ** << imports >> (editFileCommands.py)
8import difflib
9import io
10import os
11import re
12from typing import Any, List
13from leo.core import leoGlobals as g
14from leo.core import leoCommands
15from leo.commands.baseCommands import BaseEditCommandsClass
16#@-<< imports >>
18def cmd(name):
19 """Command decorator for the EditFileCommandsClass class."""
20 return g.new_cmd_decorator(name, ['c', 'editFileCommands',])
22#@+others
23#@+node:ekr.20210307060752.1: ** class ConvertAtRoot
24class ConvertAtRoot:
25 """
26 A class to convert @root directives to @clean nodes:
28 - Change @root directive in body to @clean in the headline.
29 - Make clones of section references defined outside of @clean nodes,
30 moving them so they are children of the nodes that reference them.
31 """
33 errors = 0
34 root = None # Root of @root tree.
35 root_pat = re.compile(r'^@root\s+(.+)$', re.MULTILINE)
36 section_pat = re.compile(r'\s*<\<.+>\>')
37 units: List[Any] = []
38 # List of positions containing @unit.
40 #@+others
41 #@+node:ekr.20210308044128.1: *3* atRoot.check_move
42 def check_clone_move(self, p, parent):
43 """
44 Return False if p or any of p's descendents is a clone of parent
45 or any of parents ancestors.
46 """
47 # Like as checkMoveWithParentWithWarning without warning.
48 clonedVnodes = {}
49 for ancestor in parent.self_and_parents(copy=False):
50 if ancestor.isCloned():
51 v = ancestor.v
52 clonedVnodes[v] = v
53 if not clonedVnodes:
54 return True
55 for p in p.self_and_subtree(copy=False):
56 if p.isCloned() and clonedVnodes.get(p.v):
57 return False
58 return True
59 #@+node:ekr.20210307060752.2: *3* atRoot.convert_file
60 @cmd('convert-at-root')
61 def convert_file(self, c):
62 """Convert @root to @clean in the the .leo file at the given path."""
63 self.find_all_units(c)
64 for p in c.all_positions():
65 m = self.root_pat.search(p.b)
66 path = m and m.group(1)
67 if path:
68 # Weird special case. Don't change section definition!
69 if self.section_pat.match(p.h):
70 print(f"\nCan not create @clean node: {p.h}\n")
71 self.errors += 1
72 else:
73 self.root = p.copy()
74 p.h = f"@clean {path}"
75 self.do_root(p)
76 self.root = None
77 #
78 # Check the results.
79 link_errors = c.checkOutline(check_links=True)
80 self.errors += link_errors
81 print(f"{self.errors} error{g.plural(self.errors)} in {c.shortFileName()}")
82 c.redraw()
83 # if not self.errors: self.dump(c)
84 #@+node:ekr.20210308045306.1: *3* atRoot.dump
85 def dump(self, c):
86 print(f"Dump of {c.shortFileName()}...")
87 for p in c.all_positions():
88 print(' ' * 2 * p.level(), p.h)
89 #@+node:ekr.20210307075117.1: *3* atRoot.do_root
90 def do_root(self, p):
91 """
92 Make all necessary clones for section defintions.
93 """
94 for p in p.self_and_subtree():
95 self.make_clones(p)
96 #@+node:ekr.20210307085034.1: *3* atRoot.find_all_units
97 def find_all_units(self, c):
98 """Scan for all @unit nodes."""
99 for p in c.all_positions():
100 if '@unit' in p.b:
101 self.units.append(p.copy())
102 #@+node:ekr.20210307082125.1: *3* atRoot.find_section
103 def find_section(self, root, section_name):
104 """Find the section definition node in root's subtree for the given section."""
106 def munge(s):
107 return s.strip().replace(' ', '').lower()
109 for p in root.subtree():
110 if munge(p.h).startswith(munge(section_name)):
111 # print(f" Found {section_name:30} in {root.h}::{root.gnx}")
112 return p
114 # print(f" Not found {section_name:30} in {root.h}::{root.gnx}")
115 return None
116 #@+node:ekr.20210307075325.1: *3* atRoot.make_clones
117 section_pat = re.compile(r'\s*<\<(.*)>\>')
119 def make_clones(self, p):
120 """Make clones for all undefined sections in p.b."""
121 for s in g.splitLines(p.b):
122 m = self.section_pat.match(s)
123 if m:
124 section_name = g.angleBrackets(m.group(1).strip())
125 section_p = self.make_clone(p, section_name)
126 if not section_p:
127 print(f"MISSING: {section_name:30} {p.h}")
128 self.errors += 1
129 #@+node:ekr.20210307080500.1: *3* atRoot.make_clone
130 def make_clone(self, p, section_name):
131 """Make c clone for section, if necessary."""
133 def clone_and_move(parent, section_p):
134 clone = section_p.clone()
135 if self.check_clone_move(clone, parent):
136 print(f" CLONE: {section_p.h:30} parent: {parent.h}")
137 clone.moveToLastChildOf(parent)
138 else:
139 print(f"Can not clone: {section_p.h:30} parent: {parent.h}")
140 clone.doDelete()
141 self.errors += 1
142 #
143 # First, look in p's subtree.
144 section_p = self.find_section(p, section_name)
145 if section_p:
146 # g.trace('FOUND', section_name)
147 # Already defined in a good place.
148 return section_p
149 #
150 # Finally, look in the @unit tree.
151 for unit_p in self.units:
152 section_p = self.find_section(unit_p, section_name)
153 if section_p:
154 clone_and_move(p, section_p)
155 return section_p
156 return None
157 #@-others
158#@+node:ekr.20170806094319.14: ** class EditFileCommandsClass
159class EditFileCommandsClass(BaseEditCommandsClass):
160 """A class to load files into buffers and save buffers to files."""
161 #@+others
162 #@+node:ekr.20210308051724.1: *3* efc.convert-at-root
163 @cmd('convert-at-root')
164 def convert_at_root(self, event=None):
165 #@+<< convert-at-root docstring >>
166 #@+node:ekr.20210309035627.1: *4* << convert-at-root docstring >>
167 #@@wrap
168 """
169 The convert-at-root command converts @root to @clean throughout the
170 outline.
172 This command is not perfect. You will need to adjust the outline by hand if
173 the command reports errors. I recommend using git diff to ensure that the
174 resulting external files are roughly equivalent after running this command.
176 This command attempts to do the following:
178 - For each node with an @root <path> directive in the body, change the head to
179 @clean <path>. The command does *not* change the headline if the node is
180 a section definition node. In that case, the command reports an error.
182 - Clones and moves nodes as needed so that section definition nodes appear
183 as descendants of nodes containing section references. To find section
184 definition nodes, the command looks in all @unit trees. After finding the
185 required definition node, the command makes a clone of the node and moves
186 the clone so it is the last child of the node containing the section
187 references. This move may fail. If so, the command reports an error.
188 """
189 #@-<< convert-at-root docstring >>
190 c = event.get('c')
191 if not c:
192 return
193 ConvertAtRoot().convert_file(c)
194 #@+node:ekr.20170806094319.11: *3* efc.clean-at-clean commands
195 #@+node:ekr.20170806094319.5: *4* efc.cleanAtCleanFiles
196 @cmd('clean-at-clean-files')
197 def cleanAtCleanFiles(self, event):
198 """Adjust whitespace in all @clean files."""
199 c = self.c
200 undoType = 'clean-@clean-files'
201 c.undoer.beforeChangeGroup(c.p, undoType, verboseUndoGroup=True)
202 total = 0
203 for p in c.all_unique_positions():
204 if g.match_word(p.h, 0, '@clean') and p.h.rstrip().endswith('.py'):
205 n = 0
206 for p2 in p.subtree():
207 bunch2 = c.undoer.beforeChangeNodeContents(p2)
208 if self.cleanAtCleanNode(p2, undoType):
209 n += 1
210 total += 1
211 c.undoer.afterChangeNodeContents(p2, undoType, bunch2)
212 g.es_print(f"{n} node{g.plural(n)} {p.h}")
213 if total > 0:
214 c.undoer.afterChangeGroup(c.p, undoType)
215 g.es_print(f"{total} total node{g.plural(total)}")
216 #@+node:ekr.20170806094319.8: *4* efc.cleanAtCleanNode
217 def cleanAtCleanNode(self, p, undoType):
218 """Adjust whitespace in p, part of an @clean tree."""
219 s = p.b.strip()
220 if not s or p.h.strip().startswith('<<'):
221 return False
222 ws = '\n\n' if g.match_word(s, 0, 'class') else '\n'
223 s2 = ws + s + ws
224 changed = s2 != p.b
225 if changed:
226 p.b = s2
227 p.setDirty()
228 return changed
229 #@+node:ekr.20170806094319.10: *4* efc.cleanAtCleanTree
230 @cmd('clean-at-clean-tree')
231 def cleanAtCleanTree(self, event):
232 """
233 Adjust whitespace in the nearest @clean tree,
234 searching c.p and its ancestors.
235 """
236 c = self.c
237 # Look for an @clean node.
238 for p in c.p.self_and_parents(copy=False):
239 if g.match_word(p.h, 0, '@clean') and p.h.rstrip().endswith('.py'):
240 break
241 else:
242 g.es_print('no an @clean node found', p.h, color='blue')
243 return
244 # pylint: disable=undefined-loop-variable
245 # p is certainly defined here.
246 bunch = c.undoer.beforeChangeTree(p)
247 n = 0
248 undoType = 'clean-@clean-tree'
249 for p2 in p.subtree():
250 if self.cleanAtCleanNode(p2, undoType):
251 n += 1
252 if n > 0:
253 c.setChanged()
254 c.undoer.afterChangeTree(p, undoType, bunch)
255 g.es_print(f"{n} node{g.plural(n)} cleaned")
256 #@+node:ekr.20170806094317.6: *3* efc.compareAnyTwoFiles & helpers
257 @cmd('file-compare-two-leo-files')
258 @cmd('compare-two-leo-files')
259 def compareAnyTwoFiles(self, event):
260 """Compare two files."""
261 c = c1 = self.c
262 w = c.frame.body.wrapper
263 commanders = g.app.commanders()
264 if g.app.diff:
265 if len(commanders) == 2:
266 c1, c2 = commanders
267 fn1 = g.shortFileName(c1.wrappedFileName) or c1.shortFileName()
268 fn2 = g.shortFileName(c2.wrappedFileName) or c2.shortFileName()
269 g.es('--diff auto compare', color='red')
270 g.es(fn1)
271 g.es(fn2)
272 else:
273 g.es('expecting two .leo files')
274 return
275 else:
276 # Prompt for the file to be compared with the present outline.
277 filetypes = [("Leo files", "*.leo"), ("All files", "*"),]
278 fileName = g.app.gui.runOpenFileDialog(c,
279 title="Compare .leo Files", filetypes=filetypes, defaultextension='.leo')
280 if not fileName:
281 return
282 # Read the file into the hidden commander.
283 c2 = g.createHiddenCommander(fileName)
284 if not c2:
285 return
286 # Compute the inserted, deleted and changed dicts.
287 d1 = self.createFileDict(c1)
288 d2 = self.createFileDict(c2)
289 inserted, deleted, changed = self.computeChangeDicts(d1, d2)
290 # Create clones of all inserted, deleted and changed dicts.
291 self.createAllCompareClones(c1, c2, inserted, deleted, changed)
292 # Fix bug 1231656: File-Compare-Leo-Files leaves other file open-count incremented.
293 if not g.app.diff:
294 g.app.forgetOpenFile(fn=c2.fileName())
295 c2.frame.destroySelf()
296 g.app.gui.set_focus(c, w)
297 #@+node:ekr.20170806094317.9: *4* efc.computeChangeDicts
298 def computeChangeDicts(self, d1, d2):
299 """
300 Compute inserted, deleted, changed dictionaries.
302 New in Leo 4.11: show the nodes in the *invisible* file, d2, if possible.
303 """
304 inserted = {}
305 for key in d2:
306 if not d1.get(key):
307 inserted[key] = d2.get(key)
308 deleted = {}
309 for key in d1:
310 if not d2.get(key):
311 deleted[key] = d1.get(key)
312 changed = {}
313 for key in d1:
314 if d2.get(key):
315 p1 = d1.get(key)
316 p2 = d2.get(key)
317 if p1.h != p2.h or p1.b != p2.b:
318 changed[key] = p2 # Show the node in the *other* file.
319 return inserted, deleted, changed
320 #@+node:ekr.20170806094317.11: *4* efc.createAllCompareClones & helper
321 def createAllCompareClones(self, c1, c2, inserted, deleted, changed):
322 """Create the comparison trees."""
323 c = self.c # Always use the visible commander
324 assert c == c1
325 # Create parent node at the start of the outline.
326 u, undoType = c.undoer, 'Compare Two Files'
327 u.beforeChangeGroup(c.p, undoType)
328 undoData = u.beforeInsertNode(c.p)
329 parent = c.p.insertAfter()
330 parent.setHeadString(undoType)
331 u.afterInsertNode(parent, undoType, undoData)
332 # Use the wrapped file name if possible.
333 fn1 = g.shortFileName(c1.wrappedFileName) or c1.shortFileName()
334 fn2 = g.shortFileName(c2.wrappedFileName) or c2.shortFileName()
335 for d, kind in (
336 (deleted, f"not in {fn2}"),
337 (inserted, f"not in {fn1}"),
338 (changed, f"changed: as in {fn2}"),
339 ):
340 self.createCompareClones(d, kind, parent)
341 c.selectPosition(parent)
342 u.afterChangeGroup(parent, undoType, reportFlag=True)
343 c.redraw()
344 #@+node:ekr.20170806094317.12: *5* efc.createCompareClones
345 def createCompareClones(self, d, kind, parent):
346 if d:
347 c = self.c # Use the visible commander.
348 parent = parent.insertAsLastChild()
349 parent.setHeadString(kind)
350 for key in d:
351 p = d.get(key)
352 if not kind.endswith('.leo') and p.isAnyAtFileNode():
353 # Don't make clones of @<file> nodes for wrapped files.
354 pass
355 elif p.v.context == c:
356 clone = p.clone()
357 clone.moveToLastChildOf(parent)
358 else:
359 # Fix bug 1160660: File-Compare-Leo-Files creates "other file" clones.
360 copy = p.copyTreeAfter()
361 copy.moveToLastChildOf(parent)
362 for p2 in copy.self_and_subtree(copy=False):
363 p2.v.context = c
364 #@+node:ekr.20170806094317.17: *4* efc.createFileDict
365 def createFileDict(self, c):
366 """Create a dictionary of all relevant positions in commander c."""
367 d = {}
368 for p in c.all_positions():
369 d[p.v.fileIndex] = p.copy()
370 return d
371 #@+node:ekr.20170806094317.19: *4* efc.dumpCompareNodes
372 def dumpCompareNodes(self, fileName1, fileName2, inserted, deleted, changed):
373 for d, kind in (
374 (inserted, f"inserted (only in {fileName1})"),
375 (deleted, f"deleted (only in {fileName2})"),
376 (changed, 'changed'),
377 ):
378 g.pr('\n', kind)
379 for key in d:
380 p = d.get(key)
381 g.pr(f"{key:>32} {p.h}")
382 #@+node:ekr.20170806094319.3: *3* efc.compareTrees
383 def compareTrees(self, p1, p2, tag):
386 class CompareTreesController:
387 #@+others
388 #@+node:ekr.20170806094318.18: *4* ct.compare
389 def compare(self, d1, d2, p1, p2, root):
390 """Compare dicts d1 and d2."""
391 for h in sorted(d1.keys()):
392 p1, p2 = d1.get(h), d2.get(h)
393 if h in d2:
394 lines1, lines2 = g.splitLines(p1.b), g.splitLines(p2.b)
395 aList = list(difflib.unified_diff(lines1, lines2, 'vr1', 'vr2'))
396 if aList:
397 p = root.insertAsLastChild()
398 p.h = h
399 p.b = ''.join(aList)
400 p1.clone().moveToLastChildOf(p)
401 p2.clone().moveToLastChildOf(p)
402 elif p1.b.strip():
403 # Only in p1 tree, and not an organizer node.
404 p = root.insertAsLastChild()
405 p.h = h + f"({p1.h} only)"
406 p1.clone().moveToLastChildOf(p)
407 for h in sorted(d2.keys()):
408 p2 = d2.get(h)
409 if h not in d1 and p2.b.strip():
410 # Only in p2 tree, and not an organizer node.
411 p = root.insertAsLastChild()
412 p.h = h + f"({p2.h} only)"
413 p2.clone().moveToLastChildOf(p)
414 return root
415 #@+node:ekr.20170806094318.19: *4* ct.run
416 def run(self, c, p1, p2, tag):
417 """Main line."""
418 self.c = c
419 root = c.p.insertAfter()
420 root.h = tag
421 d1 = self.scan(p1)
422 d2 = self.scan(p2)
423 self.compare(d1, d2, p1, p2, root)
424 c.p.contract()
425 root.expand()
426 c.selectPosition(root)
427 c.redraw()
428 #@+node:ekr.20170806094319.2: *4* ct.scan
429 def scan(self, p1):
430 """
431 Create a dict of the methods in p1.
432 Keys are headlines, stripped of prefixes.
433 Values are copies of positions.
434 """
435 d = {} #
436 for p in p1.self_and_subtree(copy=False):
437 h = p.h.strip()
438 i = h.find('.')
439 if i > -1:
440 h = h[i + 1 :].strip()
441 if h in d:
442 g.es_print('duplicate', p.h)
443 else:
444 d[h] = p.copy()
445 return d
446 #@-others
447 CompareTreesController().run(self.c, p1, p2, tag)
448 #@+node:ekr.20170806094318.1: *3* efc.deleteFile
449 @cmd('file-delete')
450 def deleteFile(self, event):
451 """Prompt for the name of a file and delete it."""
452 k = self.c.k
453 k.setLabelBlue('Delete File: ')
454 k.extendLabel(os.getcwd() + os.sep)
455 k.get1Arg(event, handler=self.deleteFile1)
457 def deleteFile1(self, event):
458 k = self.c.k
459 k.keyboardQuit()
460 k.clearState()
461 try:
462 os.remove(k.arg)
463 k.setStatusLabel(f"Deleted: {k.arg}")
464 except Exception:
465 k.setStatusLabel(f"Not Deleted: {k.arg}")
466 #@+node:ekr.20170806094318.3: *3* efc.diff (file-diff-files)
467 @cmd('file-diff-files')
468 def diff(self, event=None):
469 """Creates a node and puts the diff between 2 files into it."""
470 c = self.c
471 fn = self.getReadableTextFile()
472 if not fn:
473 return
474 fn2 = self.getReadableTextFile()
475 if not fn2:
476 return
477 s1, e = g.readFileIntoString(fn)
478 if s1 is None:
479 return
480 s2, e = g.readFileIntoString(fn2)
481 if s2 is None:
482 return
483 lines1, lines2 = g.splitLines(s1), g.splitLines(s2)
484 aList = difflib.ndiff(lines1, lines2)
485 p = c.p.insertAfter()
486 p.h = 'diff'
487 p.b = ''.join(aList)
488 c.redraw()
489 #@+node:ekr.20170806094318.6: *3* efc.getReadableTextFile
490 def getReadableTextFile(self):
491 """Prompt for a text file."""
492 c = self.c
493 fn = g.app.gui.runOpenFileDialog(c,
494 title='Open Text File',
495 filetypes=[("Text", "*.txt"), ("All files", "*")],
496 defaultextension=".txt")
497 return fn
498 #@+node:ekr.20170819035801.90: *3* efc.gitDiff (gd & git-diff)
499 @cmd('git-diff')
500 @cmd('gd')
501 def gitDiff(self, event=None): # 2020/07/18, for leoInteg.
502 """Produce a Leonine git diff."""
503 GitDiffController(c=self.c).git_diff(rev1='HEAD')
504 #@+node:ekr.20201215093414.1: *3* efc.gitDiffPR (git-diff-pr & git-diff-pull-request)
505 @cmd('git-diff-pull-request')
506 @cmd('git-diff-pr')
507 def gitDiffPullRequest(self, event=None):
508 """
509 Produce a Leonine diff of pull request in the current branch.
510 """
511 GitDiffController(c=self.c).diff_pull_request()
512 #@+node:ekr.20170806094318.7: *3* efc.insertFile
513 @cmd('file-insert')
514 def insertFile(self, event):
515 """Prompt for the name of a file and put the selected text into it."""
516 w = self.editWidget(event)
517 if not w:
518 return
519 fn = self.getReadableTextFile()
520 if not fn:
521 return
522 s, e = g.readFileIntoString(fn)
523 if s:
524 self.beginCommand(w, undoType='insert-file')
525 i = w.getInsertPoint()
526 w.insert(i, s)
527 w.seeInsertPoint()
528 self.endCommand(changed=True, setLabel=True)
529 #@+node:ekr.20170806094318.9: *3* efc.makeDirectory
530 @cmd('directory-make')
531 def makeDirectory(self, event):
532 """Prompt for the name of a directory and create it."""
533 k = self.c.k
534 k.setLabelBlue('Make Directory: ')
535 k.extendLabel(os.getcwd() + os.sep)
536 k.get1Arg(event, handler=self.makeDirectory1)
537 def makeDirectory1(self, event):
538 k = self.c.k
539 k.keyboardQuit()
540 k.clearState()
541 try:
542 os.mkdir(k.arg)
543 k.setStatusLabel(f"Created: {k.arg}")
544 except Exception:
545 k.setStatusLabel(f"Not Created: {k.arg}")
546 #@+node:ekr.20170806094318.12: *3* efc.openOutlineByName
547 @cmd('file-open-by-name')
548 def openOutlineByName(self, event):
549 """file-open-by-name: Prompt for the name of a Leo outline and open it."""
550 c, k = self.c, self.c.k
551 fileName = ''.join(k.givenArgs)
552 # Bug fix: 2012/04/09: only call g.openWithFileName if the file exists.
553 if fileName and g.os_path_exists(fileName):
554 g.openWithFileName(fileName, old_c=c)
555 else:
556 k.setLabelBlue('Open Leo Outline: ')
557 k.getFileName(event, callback=self.openOutlineByNameFinisher)
559 def openOutlineByNameFinisher(self, fn):
560 c = self.c
561 if fn and g.os_path_exists(fn) and not g.os_path_isdir(fn):
562 c2 = g.openWithFileName(fn, old_c=c)
563 try:
564 g.app.gui.runAtIdle(c2.treeWantsFocusNow)
565 except Exception:
566 pass
567 else:
568 g.es(f"ignoring: {fn}")
569 #@+node:ekr.20170806094318.14: *3* efc.removeDirectory
570 @cmd('directory-remove')
571 def removeDirectory(self, event):
572 """Prompt for the name of a directory and delete it."""
573 k = self.c.k
574 k.setLabelBlue('Remove Directory: ')
575 k.extendLabel(os.getcwd() + os.sep)
576 k.get1Arg(event, handler=self.removeDirectory1)
578 def removeDirectory1(self, event):
579 k = self.c.k
580 k.keyboardQuit()
581 k.clearState()
582 try:
583 os.rmdir(k.arg)
584 k.setStatusLabel(f"Removed: {k.arg}")
585 except Exception:
586 k.setStatusLabel(f"Not Removed: {k.arg}")
587 #@+node:ekr.20170806094318.15: *3* efc.saveFile (save-file-by-name)
588 @cmd('file-save-by-name')
589 @cmd('save-file-by-name')
590 def saveFile(self, event):
591 """Prompt for the name of a file and put the body text of the selected node into it.."""
592 c = self.c
593 w = self.editWidget(event)
594 if not w:
595 return
596 fileName = g.app.gui.runSaveFileDialog(c,
597 title='save-file',
598 filetypes=[("Text", "*.txt"), ("All files", "*")],
599 defaultextension=".txt")
600 if fileName:
601 try:
602 s = w.getAllText()
603 with open(fileName, 'w') as f:
604 f.write(s)
605 except IOError:
606 g.es('can not create', fileName)
607 #@+node:ekr.20170806094319.15: *3* efc.toggleAtAutoAtEdit & helpers
608 @cmd('toggle-at-auto-at-edit')
609 def toggleAtAutoAtEdit(self, event):
610 """Toggle between @auto and @edit, preserving insert point, etc."""
611 p = self.c.p
612 if p.isAtEditNode():
613 self.toAtAuto(p)
614 return
615 for p in p.self_and_parents():
616 if p.isAtAutoNode():
617 self.toAtEdit(p)
618 return
619 g.es_print('Not in an @auto or @edit tree.', color='blue')
620 #@+node:ekr.20170806094319.17: *4* efc.toAtAuto
621 def toAtAuto(self, p):
622 """Convert p from @edit to @auto."""
623 c = self.c
624 # Change the headline.
625 p.h = '@auto' + p.h[5:]
626 # Compute the position of the present line within the file.
627 w = c.frame.body.wrapper
628 ins = w.getInsertPoint()
629 row, col = g.convertPythonIndexToRowCol(p.b, ins)
630 # Ignore *preceding* directive lines.
631 directives = [z for z in g.splitLines(c.p.b)[:row] if g.isDirective(z)]
632 row -= len(directives)
633 row = max(0, row)
634 # Reload the file, creating new nodes.
635 c.selectPosition(p)
636 c.refreshFromDisk()
637 # Restore the line in the proper node.
638 c.gotoCommands.find_file_line(row + 1)
639 p.setDirty()
640 c.setChanged()
641 c.redraw()
642 c.bodyWantsFocus()
643 #@+node:ekr.20170806094319.19: *4* efc.toAtEdit
644 def toAtEdit(self, p):
645 """Convert p from @auto to @edit."""
646 c = self.c
647 w = c.frame.body.wrapper
648 p.h = '@edit' + p.h[5:]
649 # Compute the position of the present line within the *selected* node c.p
650 ins = w.getInsertPoint()
651 row, col = g.convertPythonIndexToRowCol(c.p.b, ins)
652 # Ignore directive lines.
653 directives = [z for z in g.splitLines(c.p.b)[:row] if g.isDirective(z)]
654 row -= len(directives)
655 row = max(0, row)
656 # Count preceding lines from p to c.p, again ignoring directives.
657 for p2 in p.self_and_subtree(copy=False):
658 if p2 == c.p:
659 break
660 lines = [z for z in g.splitLines(p2.b) if not g.isDirective(z)]
661 row += len(lines)
662 # Reload the file into a single node.
663 c.selectPosition(p)
664 c.refreshFromDisk()
665 # Restore the line in the proper node.
666 ins = g.convertRowColToPythonIndex(p.b, row + 1, 0)
667 w.setInsertPoint(ins)
668 p.setDirty()
669 c.setChanged()
670 c.redraw()
671 c.bodyWantsFocus()
672 #@-others
673#@+node:ekr.20170806094320.13: ** class GitDiffController
674class GitDiffController:
675 """A class to do git diffs."""
677 def __init__(self, c):
678 self.c = c
679 self.file_node = None
680 self.root = None
681 #@+others
682 #@+node:ekr.20180510095544.1: *3* gdc.Entries...
683 #@+node:ekr.20170806094320.6: *4* gdc.diff_file
684 def diff_file(self, fn, rev1='HEAD', rev2=''):
685 """
686 Create an outline describing the git diffs for fn.
687 """
688 # Common code.
689 c = self.c
690 # #1781, #2143
691 directory = self.get_directory()
692 if not directory:
693 return
694 s1 = self.get_file_from_rev(rev1, fn)
695 s2 = self.get_file_from_rev(rev2, fn)
696 lines1 = g.splitLines(s1)
697 lines2 = g.splitLines(s2)
698 diff_list = list(difflib.unified_diff(
699 lines1,
700 lines2,
701 rev1 or 'uncommitted',
702 rev2 or 'uncommitted',
703 ))
704 diff_list.insert(0, '@ignore\n@nosearch\n@language patch\n')
705 self.file_node = self.create_file_node(diff_list, fn)
706 # #1777: The file node will contain the entire added/deleted file.
707 if not s1:
708 self.file_node.h = f"Added: {self.file_node.h}"
709 return
710 if not s2:
711 self.file_node.h = f"Deleted: {self.file_node.h}"
712 return
713 # Finish.
714 path = g.os_path_finalize_join(directory, fn) # #1781: bug fix.
715 c1 = c2 = None
716 if fn.endswith('.leo'):
717 c1 = self.make_leo_outline(fn, path, s1, rev1)
718 c2 = self.make_leo_outline(fn, path, s2, rev2)
719 else:
720 root = self.find_file(fn)
721 if c.looksLikeDerivedFile(path):
722 c1 = self.make_at_file_outline(fn, s1, rev1)
723 c2 = self.make_at_file_outline(fn, s2, rev2)
724 elif root:
725 c1 = self.make_at_clean_outline(fn, root, s1, rev1)
726 c2 = self.make_at_clean_outline(fn, root, s2, rev2)
727 if c1 and c2:
728 self.make_diff_outlines(c1, c2, fn, rev1, rev2)
729 self.file_node.b = (
730 f"{self.file_node.b.rstrip()}\n"
731 f"@language {c2.target_language}\n")
732 #@+node:ekr.20201208115447.1: *4* gdc.diff_pull_request
733 def diff_pull_request(self):
734 """
735 Create a Leonine version of the diffs that would be
736 produced by a pull request between two branches.
737 """
738 directory = self.get_directory()
739 if not directory:
740 return
741 aList = g.execGitCommand("git rev-parse devel", directory)
742 if aList:
743 devel_rev = aList[0]
744 devel_rev = devel_rev[:8]
745 self.diff_two_revs(
746 rev1=devel_rev, # Before: Latest devel commit.
747 rev2='HEAD', # After: Lastest branch commit
748 )
749 else:
750 g.es_print('FAIL: git rev-parse devel')
751 #@+node:ekr.20180506064102.10: *4* gdc.diff_two_branches
752 def diff_two_branches(self, branch1, branch2, fn):
753 """Create an outline describing the git diffs for fn."""
754 c = self.c
755 if not self.get_directory():
756 return
757 self.root = p = c.lastTopLevel().insertAfter()
758 p.h = f"git-diff-branches {branch1} {branch2}"
759 s1 = self.get_file_from_branch(branch1, fn)
760 s2 = self.get_file_from_branch(branch2, fn)
761 lines1 = g.splitLines(s1)
762 lines2 = g.splitLines(s2)
763 diff_list = list(difflib.unified_diff(lines1, lines2, branch1, branch2,))
764 diff_list.insert(0, '@ignore\n@nosearch\n@language patch\n')
765 self.file_node = self.create_file_node(diff_list, fn)
766 if c.looksLikeDerivedFile(fn):
767 c1 = self.make_at_file_outline(fn, s1, branch1)
768 c2 = self.make_at_file_outline(fn, s2, branch2)
769 else:
770 root = self.find_file(fn)
771 if root:
772 c1 = self.make_at_clean_outline(fn, root, s1, branch1)
773 c2 = self.make_at_clean_outline(fn, root, s2, branch2)
774 else:
775 c1 = c2 = None
776 if c1 and c2:
777 self.make_diff_outlines(c1, c2, fn)
778 self.file_node.b = f"{self.file_node.b.rstrip()}\n@language {c2.target_language}\n"
779 self.finish()
780 #@+node:ekr.20180507212821.1: *4* gdc.diff_two_revs
781 def diff_two_revs(self, rev1='HEAD', rev2=''):
782 """
783 Create an outline describing the git diffs for all files changed
784 between rev1 and rev2.
785 """
786 c = self.c
787 if not self.get_directory():
788 return
789 # Get list of changed files.
790 files = self.get_files(rev1, rev2)
791 n = len(files)
792 message = f"diffing {n} file{g.plural(n)}"
793 if n > 5:
794 message += ". This may take awhile..."
795 g.es_print(message)
796 # Create the root node.
797 self.root = c.lastTopLevel().insertAfter()
798 self.root.h = f"git diff revs: {rev1} {rev2}"
799 self.root.b = '@ignore\n@nosearch\n'
800 # Create diffs of all files.
801 for fn in files:
802 self.diff_file(fn=fn, rev1=rev1, rev2=rev2)
803 self.finish()
804 #@+node:ekr.20170806094320.12: *4* gdc.git_diff & helper
805 def git_diff(self, rev1='HEAD', rev2=''):
806 """The main line of the git diff command."""
807 if not self.get_directory():
808 return
809 #
810 # Diff the given revs.
811 ok = self.diff_revs(rev1, rev2)
812 if ok:
813 return
814 #
815 # Go back at most 5 revs...
816 n1, n2 = 1, 0
817 while n1 <= 5:
818 ok = self.diff_revs(
819 # Clearer w/o f-strings.
820 rev1=f"HEAD@{{{n1}}}",
821 rev2=f"HEAD@{{{n2}}}")
822 if ok:
823 return
824 n1, n2 = n1 + 1, n2 + 1
825 if not ok:
826 g.es_print('no changed readable files from HEAD@{1}..HEAD@{5}')
827 #@+node:ekr.20170820082125.1: *5* gdc.diff_revs
828 def diff_revs(self, rev1, rev2):
829 """Diff all files given by rev1 and rev2."""
830 files = self.get_files(rev1, rev2)
831 if files:
832 self.root = self.create_root(rev1, rev2)
833 for fn in files:
834 self.diff_file(fn=fn, rev1=rev1, rev2=rev2)
835 self.finish()
836 return bool(files)
837 #@+node:ekr.20180510095801.1: *3* gdc.Utils
838 #@+node:ekr.20170806191942.2: *4* gdc.create_compare_node
839 def create_compare_node(self, c1, c2, d, kind, rev1, rev2):
840 """Create nodes describing the changes."""
841 if not d:
842 return
843 parent = self.file_node.insertAsLastChild()
844 parent.setHeadString(kind)
845 for key in d:
846 if kind.lower() == 'changed':
847 v1, v2 = d.get(key)
848 # Organizer node: contains diff
849 organizer = parent.insertAsLastChild()
850 organizer.h = v2.h
851 body = list(difflib.unified_diff(
852 g.splitLines(v1.b),
853 g.splitLines(v2.b),
854 rev1 or 'uncommitted',
855 rev2 or 'uncommitted',
856 ))
857 if ''.join(body).strip():
858 body.insert(0, '@ignore\n@nosearch\n@language patch\n')
859 body.append(f"@language {c2.target_language}\n")
860 else:
861 body = ['Only headline has changed']
862 organizer.b = ''.join(body)
863 # Node 2: Old node
864 p2 = organizer.insertAsLastChild()
865 p2.h = 'Old:' + v1.h
866 p2.b = v1.b
867 # Node 3: New node
868 assert v1.fileIndex == v2.fileIndex
869 p_in_c = self.find_gnx(self.c, v1.fileIndex)
870 if p_in_c: # Make a clone, if possible.
871 p3 = p_in_c.clone()
872 p3.moveToLastChildOf(organizer)
873 else:
874 p3 = organizer.insertAsLastChild()
875 p3.h = 'New:' + v2.h
876 p3.b = v2.b
877 elif kind.lower() == 'added':
878 v = d.get(key)
879 new_p = self.find_gnx(self.c, v.fileIndex)
880 if new_p: # Make a clone, if possible.
881 p = new_p.clone()
882 p.moveToLastChildOf(parent)
883 else:
884 p = parent.insertAsLastChild()
885 p.h = v.h
886 p.b = v.b
887 else:
888 v = d.get(key)
889 p = parent.insertAsLastChild()
890 p.h = v.h
891 p.b = v.b
892 #@+node:ekr.20170806094321.1: *4* gdc.create_file_node
893 def create_file_node(self, diff_list, fn):
894 """Create an organizer node for the file."""
895 p = self.root.insertAsLastChild()
896 p.h = fn.strip()
897 p.b = ''.join(diff_list)
898 return p
899 #@+node:ekr.20170806094320.18: *4* gdc.create_root
900 def create_root(self, rev1, rev2):
901 """Create the top-level organizer node describing the git diff."""
902 c = self.c
903 r1, r2 = rev1 or '', rev2 or ''
904 p = c.lastTopLevel().insertAfter()
905 p.h = f"git diff {r1} {r2}"
906 p.b = '@ignore\n@nosearch\n'
907 if r1 and r2:
908 p.b += (
909 f"{r1}={self.get_revno(r1)}\n"
910 f"{r2}={self.get_revno(r2)}")
911 else:
912 p.b += f"{r1}={self.get_revno(r1)}"
913 return p
914 #@+node:ekr.20170806094320.7: *4* gdc.find_file
915 def find_file(self, fn):
916 """Return the @<file> node matching fn."""
917 c = self.c
918 fn = g.os_path_basename(fn)
919 for p in c.all_unique_positions():
920 if p.isAnyAtFileNode():
921 fn2 = p.anyAtFileNodeName()
922 if fn2.endswith(fn):
923 return p
924 return None
925 #@+node:ekr.20170806094321.3: *4* gdc.find_git_working_directory
926 def find_git_working_directory(self, directory):
927 """Return the git working directory, starting at directory."""
928 while directory:
929 if g.os_path_exists(g.os_path_finalize_join(directory, '.git')):
930 return directory
931 path2 = g.os_path_finalize_join(directory, '..')
932 if path2 == directory:
933 break
934 directory = path2
935 return None
936 #@+node:ekr.20170819132219.1: *4* gdc.find_gnx
937 def find_gnx(self, c, gnx):
938 """Return a position in c having the given gnx."""
939 for p in c.all_unique_positions():
940 if p.v.fileIndex == gnx:
941 return p
942 return None
943 #@+node:ekr.20170806094321.5: *4* gdc.finish
944 def finish(self):
945 """Finish execution of this command."""
946 c = self.c
947 c.selectPosition(self.root)
948 self.root.expand()
949 c.redraw(self.root)
950 c.treeWantsFocusNow()
951 #@+node:ekr.20210819080657.1: *4* gdc.get_directory
952 def get_directory(self):
953 """
954 #2143.
955 Resolve filename to the nearest directory containing a .git directory.
956 """
957 c = self.c
958 filename = c.fileName()
959 if not filename:
960 print('git-diff: outline has no name')
961 return None
962 directory = os.path.dirname(filename)
963 if directory and not os.path.isdir(directory):
964 directory = os.path.dirname(directory)
965 if not directory:
966 print(f"git-diff: outline has no directory. filename: {filename!r}")
967 return None
968 # Does path/../ref exist?
969 base_directory = g.gitHeadPath(directory)
970 if not base_directory:
971 print(f"git-diff: no .git directory: {directory!r} filename: {filename!r}")
972 return None
973 # This should guarantee that the directory contains a .git directory.
974 directory = g.os_path_finalize_join(base_directory, '..', '..')
975 return directory
976 #@+node:ekr.20180506064102.11: *4* gdc.get_file_from_branch
977 def get_file_from_branch(self, branch, fn):
978 """Get the file from the head of the given branch."""
979 # #2143
980 directory = self.get_directory()
981 if not directory:
982 return ''
983 command = f"git show {branch}:{fn}"
984 lines = g.execGitCommand(command, directory)
985 s = ''.join(lines)
986 return g.toUnicode(s).replace('\r', '')
987 #@+node:ekr.20170806094320.15: *4* gdc.get_file_from_rev
988 def get_file_from_rev(self, rev, fn):
989 """Get the file from the given rev, or the working directory if None."""
990 # #2143
991 directory = self.get_directory()
992 if not directory:
993 return ''
994 path = g.os_path_finalize_join(directory, fn)
995 if not g.os_path_exists(path):
996 g.trace(f"File not found: {path!r} fn: {fn!r}")
997 return ''
998 if rev:
999 # Get the file using git.
1000 # Use the file name, not the path.
1001 command = f"git show {rev}:{fn}"
1002 lines = g.execGitCommand(command, directory)
1003 return g.toUnicode(''.join(lines)).replace('\r', '')
1004 try:
1005 with open(path, 'rb') as f:
1006 b = f.read()
1007 return g.toUnicode(b).replace('\r', '')
1008 except Exception:
1009 g.es_print('Can not read', path)
1010 g.es_exception()
1011 return ''
1012 #@+node:ekr.20170806094320.9: *4* gdc.get_files
1013 def get_files(self, rev1, rev2):
1014 """Return a list of changed files."""
1015 # #2143
1016 directory = self.get_directory()
1017 if not directory:
1018 return []
1019 command = f"git diff --name-only {(rev1 or '')} {(rev2 or '')}"
1020 # #1781: Allow diffs of .leo files.
1021 return [
1022 z.strip() for z in g.execGitCommand(command, directory)
1023 if not z.strip().endswith(('.db', '.zip'))
1024 ]
1025 #@+node:ekr.20170821052348.1: *4* gdc.get_revno
1026 def get_revno(self, revspec, abbreviated=True):
1027 """Return the abbreviated hash the given revision spec."""
1028 if not revspec:
1029 return 'uncommitted'
1030 # Return only the abbreviated hash for the revspec.
1031 format_s = 'h' if abbreviated else 'H'
1032 command = f"git show --format=%{format_s} --no-patch {revspec}"
1033 directory = self.get_directory()
1034 lines = g.execGitCommand(command, directory=directory)
1035 return ''.join(lines).strip()
1036 #@+node:ekr.20170820084258.1: *4* gdc.make_at_clean_outline
1037 def make_at_clean_outline(self, fn, root, s, rev):
1038 """
1039 Create a hidden temp outline from lines without sentinels.
1040 root is the @<file> node for fn.
1041 s is the contents of the (public) file, without sentinels.
1042 """
1043 # A specialized version of at.readOneAtCleanNode.
1044 hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
1045 at = hidden_c.atFileCommands
1046 x = hidden_c.shadowController
1047 hidden_c.frame.createFirstTreeNode()
1048 hidden_root = hidden_c.rootPosition()
1049 # copy root to hidden root, including gnxs.
1050 root.copyTreeFromSelfTo(hidden_root, copyGnxs=True)
1051 hidden_root.h = fn + ':' + rev if rev else fn
1052 # Set at.encoding first.
1053 at.initReadIvars(hidden_root, fn)
1054 # Must be called before at.scanAllDirectives.
1055 at.scanAllDirectives(hidden_root)
1056 # Sets at.startSentinelComment/endSentinelComment.
1057 new_public_lines = g.splitLines(s)
1058 old_private_lines = at.write_at_clean_sentinels(hidden_root)
1059 marker = x.markerFromFileLines(old_private_lines, fn)
1060 old_public_lines, junk = x.separate_sentinels(old_private_lines, marker)
1061 if old_public_lines:
1062 # Fix #1136: The old lines might not exist.
1063 new_private_lines = x.propagate_changed_lines(
1064 new_public_lines, old_private_lines, marker, p=hidden_root)
1065 at.fast_read_into_root(
1066 c=hidden_c,
1067 contents=''.join(new_private_lines),
1068 gnx2vnode={},
1069 path=fn,
1070 root=hidden_root,
1071 )
1072 return hidden_c
1073 #@+node:ekr.20170806094321.7: *4* gdc.make_at_file_outline
1074 def make_at_file_outline(self, fn, s, rev):
1075 """Create a hidden temp outline from lines."""
1076 # A specialized version of atFileCommands.read.
1077 hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
1078 at = hidden_c.atFileCommands
1079 hidden_c.frame.createFirstTreeNode()
1080 root = hidden_c.rootPosition()
1081 root.h = fn + ':' + rev if rev else fn
1082 at.initReadIvars(root, fn)
1083 if at.errors > 0:
1084 g.trace('***** errors')
1085 return None
1086 at.fast_read_into_root(
1087 c=hidden_c,
1088 contents=s,
1089 gnx2vnode={},
1090 path=fn,
1091 root=root,
1092 )
1093 return hidden_c
1094 #@+node:ekr.20170806125535.1: *4* gdc.make_diff_outlines & helper
1095 def make_diff_outlines(self, c1, c2, fn, rev1='', rev2=''):
1096 """Create an outline-oriented diff from the *hidden* outlines c1 and c2."""
1097 added, deleted, changed = self.compute_dicts(c1, c2)
1098 table = (
1099 (added, 'Added'),
1100 (deleted, 'Deleted'),
1101 (changed, 'Changed'))
1102 for d, kind in table:
1103 self.create_compare_node(c1, c2, d, kind, rev1, rev2)
1104 #@+node:ekr.20170806191707.1: *5* gdc.compute_dicts
1105 def compute_dicts(self, c1, c2):
1106 """Compute inserted, deleted, changed dictionaries."""
1107 # Special case the root: only compare the body text.
1108 root1, root2 = c1.rootPosition().v, c2.rootPosition().v
1109 root1.h = root2.h
1110 if 0:
1111 g.trace('c1...')
1112 for p in c1.all_positions():
1113 print(f"{len(p.b):4} {p.h}")
1114 g.trace('c2...')
1115 for p in c2.all_positions():
1116 print(f"{len(p.b):4} {p.h}")
1117 d1 = {v.fileIndex: v for v in c1.all_unique_nodes()}
1118 d2 = {v.fileIndex: v for v in c2.all_unique_nodes()}
1119 added = {key: d2.get(key) for key in d2 if not d1.get(key)}
1120 deleted = {key: d1.get(key) for key in d1 if not d2.get(key)}
1121 # Remove the root from the added and deleted dicts.
1122 if root2.fileIndex in added:
1123 del added[root2.fileIndex]
1124 if root1.fileIndex in deleted:
1125 del deleted[root1.fileIndex]
1126 changed = {}
1127 for key in d1:
1128 if key in d2:
1129 v1 = d1.get(key)
1130 v2 = d2.get(key)
1131 assert v1 and v2
1132 assert v1.context != v2.context
1133 if v1.h != v2.h or v1.b != v2.b:
1134 changed[key] = (v1, v2)
1135 return added, deleted, changed
1136 #@+node:ekr.20201215050832.1: *4* gdc.make_leo_outline
1137 def make_leo_outline(self, fn, path, s, rev):
1138 """Create a hidden temp outline for the .leo file in s."""
1139 hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
1140 hidden_c.frame.createFirstTreeNode()
1141 root = hidden_c.rootPosition()
1142 root.h = fn + ':' + rev if rev else fn
1143 hidden_c.fileCommands.getLeoFile(
1144 theFile=io.StringIO(initial_value=s),
1145 fileName=path,
1146 readAtFileNodesFlag=False,
1147 silent=False,
1148 checkOpenFiles=False,
1149 )
1150 return hidden_c
1151 #@-others
1152#@-others
1153#@@language python
1154#@-leo