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