|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a widget to visualize the Python AST for some Python |
|
8 sources. |
|
9 """ |
|
10 |
|
11 import ast |
|
12 |
|
13 from PyQt6.QtCore import pyqtSlot, Qt, QTimer |
|
14 from PyQt6.QtGui import QBrush |
|
15 from PyQt6.QtWidgets import ( |
|
16 QTreeWidget, QTreeWidgetItem, QAbstractItemView, QWidget, QVBoxLayout |
|
17 ) |
|
18 |
|
19 from asttokens import ASTTokens |
|
20 |
|
21 from EricGui.EricOverrideCursor import EricOverrideCursor |
|
22 |
|
23 import Preferences |
|
24 |
|
25 |
|
26 class PythonAstViewer(QWidget): |
|
27 """ |
|
28 Class implementing a widget to visualize the Python AST for some Python |
|
29 sources. |
|
30 """ |
|
31 StartLineRole = Qt.ItemDataRole.UserRole |
|
32 StartIndexRole = Qt.ItemDataRole.UserRole + 1 |
|
33 EndLineRole = Qt.ItemDataRole.UserRole + 2 |
|
34 EndIndexRole = Qt.ItemDataRole.UserRole + 3 |
|
35 |
|
36 def __init__(self, viewmanager, parent=None): |
|
37 """ |
|
38 Constructor |
|
39 |
|
40 @param viewmanager reference to the viewmanager object |
|
41 @type ViewManager |
|
42 @param parent reference to the parent widget |
|
43 @type QWidget |
|
44 """ |
|
45 super().__init__(parent) |
|
46 |
|
47 self.__layout = QVBoxLayout(self) |
|
48 self.setLayout(self.__layout) |
|
49 self.__astWidget = QTreeWidget(self) |
|
50 self.__layout.addWidget(self.__astWidget) |
|
51 self.__layout.setContentsMargins(0, 0, 0, 0) |
|
52 |
|
53 self.__vm = viewmanager |
|
54 self.__vmConnected = False |
|
55 |
|
56 self.__editor = None |
|
57 self.__source = "" |
|
58 |
|
59 self.__astWidget.setHeaderLabels([self.tr("Node"), |
|
60 self.tr("Code Range")]) |
|
61 self.__astWidget.setSortingEnabled(False) |
|
62 self.__astWidget.setSelectionBehavior( |
|
63 QAbstractItemView.SelectionBehavior.SelectRows) |
|
64 self.__astWidget.setSelectionMode( |
|
65 QAbstractItemView.SelectionMode.SingleSelection) |
|
66 self.__astWidget.setAlternatingRowColors(True) |
|
67 |
|
68 self.__errorColor = QBrush( |
|
69 Preferences.getPython("ASTViewerErrorColor")) |
|
70 |
|
71 self.__astWidget.itemClicked.connect(self.__astItemClicked) |
|
72 |
|
73 self.__vm.astViewerStateChanged.connect(self.__astViewerStateChanged) |
|
74 |
|
75 self.hide() |
|
76 |
|
77 def __editorChanged(self, editor): |
|
78 """ |
|
79 Private slot to handle a change of the current editor. |
|
80 |
|
81 @param editor reference to the current editor |
|
82 @type Editor |
|
83 """ |
|
84 if editor is not self.__editor: |
|
85 if self.__editor: |
|
86 self.__editor.clearAllHighlights() |
|
87 self.__editor = editor |
|
88 if self.__editor: |
|
89 self.__loadAST() |
|
90 |
|
91 def __editorSaved(self, editor): |
|
92 """ |
|
93 Private slot to reload the AST after the connected editor was saved. |
|
94 |
|
95 @param editor reference to the editor that performed a save action |
|
96 @type Editor |
|
97 """ |
|
98 if editor and editor is self.__editor: |
|
99 self.__loadAST() |
|
100 |
|
101 def __editorDoubleClicked(self, editor, pos, buttons): |
|
102 """ |
|
103 Private slot to handle a mouse button double click in the editor. |
|
104 |
|
105 @param editor reference to the editor, that emitted the signal |
|
106 @type Editor |
|
107 @param pos position of the double click |
|
108 @type QPoint |
|
109 @param buttons mouse buttons that were double clicked |
|
110 @type Qt.MouseButtons |
|
111 """ |
|
112 if editor is self.__editor and buttons == Qt.MouseButton.LeftButton: |
|
113 if editor.isModified(): |
|
114 # reload the source |
|
115 QTimer.singleShot(0, self.__loadAST) |
|
116 |
|
117 # highlight the corresponding entry |
|
118 QTimer.singleShot(0, self.__selectItemForEditorSelection) |
|
119 QTimer.singleShot(0, self.__grabFocus) |
|
120 |
|
121 def __editorLanguageChanged(self, editor): |
|
122 """ |
|
123 Private slot to handle a change of the editor language. |
|
124 |
|
125 @param editor reference to the editor which changed language |
|
126 @type Editor |
|
127 """ |
|
128 if editor is self.__editor: |
|
129 QTimer.singleShot(0, self.__loadDIS) |
|
130 |
|
131 def __lastEditorClosed(self): |
|
132 """ |
|
133 Private slot to handle the last editor closed signal of the view |
|
134 manager. |
|
135 """ |
|
136 self.hide() |
|
137 |
|
138 def show(self): |
|
139 """ |
|
140 Public slot to show the AST viewer. |
|
141 """ |
|
142 super().show() |
|
143 |
|
144 if not self.__vmConnected: |
|
145 self.__vm.editorChangedEd.connect(self.__editorChanged) |
|
146 self.__vm.editorSavedEd.connect(self.__editorSaved) |
|
147 self.__vm.editorDoubleClickedEd.connect(self.__editorDoubleClicked) |
|
148 self.__vm.editorLanguageChanged.connect( |
|
149 self.__editorLanguageChanged) |
|
150 self.__vmConnected = True |
|
151 |
|
152 def hide(self): |
|
153 """ |
|
154 Public slot to hide the AST viewer. |
|
155 """ |
|
156 super().hide() |
|
157 |
|
158 if self.__editor: |
|
159 self.__editor.clearAllHighlights() |
|
160 |
|
161 if self.__vmConnected: |
|
162 self.__vm.editorChangedEd.disconnect(self.__editorChanged) |
|
163 self.__vm.editorSavedEd.disconnect(self.__editorSaved) |
|
164 self.__vm.editorDoubleClickedEd.disconnect( |
|
165 self.__editorDoubleClicked) |
|
166 self.__vm.editorLanguageChanged.disconnect( |
|
167 self.__editorLanguageChanged) |
|
168 self.__vmConnected = False |
|
169 |
|
170 def shutdown(self): |
|
171 """ |
|
172 Public method to perform shutdown actions. |
|
173 """ |
|
174 self.__editor = None |
|
175 |
|
176 def __astViewerStateChanged(self, on): |
|
177 """ |
|
178 Private slot to toggle the display of the AST viewer. |
|
179 |
|
180 @param on flag indicating to show the AST |
|
181 @type bool |
|
182 """ |
|
183 editor = self.__vm.activeWindow() |
|
184 if on: |
|
185 if editor is not self.__editor: |
|
186 self.__editor = editor |
|
187 self.show() |
|
188 self.__loadAST() |
|
189 else: |
|
190 self.hide() |
|
191 self.__editor = None |
|
192 |
|
193 def __createErrorItem(self, error): |
|
194 """ |
|
195 Private method to create a top level error item. |
|
196 |
|
197 @param error error message |
|
198 @type str |
|
199 @return generated item |
|
200 @rtype QTreeWidgetItem |
|
201 """ |
|
202 itm = QTreeWidgetItem(self.__astWidget, [error]) |
|
203 itm.setFirstColumnSpanned(True) |
|
204 itm.setForeground(0, self.__errorColor) |
|
205 return itm |
|
206 |
|
207 def __loadAST(self): |
|
208 """ |
|
209 Private method to generate the AST from the source of the current |
|
210 editor and visualize it. |
|
211 """ |
|
212 if not self.__editor: |
|
213 self.__createErrorItem(self.tr( |
|
214 "No editor has been opened." |
|
215 )) |
|
216 return |
|
217 |
|
218 self.__astWidget.clear() |
|
219 self.__editor.clearAllHighlights() |
|
220 |
|
221 source = self.__editor.text() |
|
222 if not source.strip(): |
|
223 # empty editor or white space only |
|
224 self.__createErrorItem(self.tr( |
|
225 "The current editor does not contain any source code." |
|
226 )) |
|
227 return |
|
228 |
|
229 if not self.__editor.isPyFile(): |
|
230 self.__createErrorItem(self.tr( |
|
231 "The current editor does not contain Python source code." |
|
232 )) |
|
233 return |
|
234 |
|
235 with EricOverrideCursor(): |
|
236 try: |
|
237 # generate the AST |
|
238 root = ast.parse(source, self.__editor.getFileName(), "exec") |
|
239 self.__markTextRanges(root, source) |
|
240 astValid = True |
|
241 except Exception as exc: |
|
242 self.__createErrorItem(str(exc)) |
|
243 astValid = False |
|
244 |
|
245 if astValid: |
|
246 self.setUpdatesEnabled(False) |
|
247 |
|
248 # populate the AST tree |
|
249 self.__populateNode(self.tr("Module"), root, self.__astWidget) |
|
250 self.__selectItemForEditorSelection() |
|
251 QTimer.singleShot(0, self.__resizeColumns) |
|
252 |
|
253 self.setUpdatesEnabled(True) |
|
254 |
|
255 self.__grabFocus() |
|
256 |
|
257 def __populateNode(self, name, nodeOrFields, parent): |
|
258 """ |
|
259 Private method to populate the tree view with a node. |
|
260 |
|
261 @param name name of the node |
|
262 @type str |
|
263 @param nodeOrFields reference to the node or a list node fields |
|
264 @type ast.AST or list |
|
265 @param parent reference to the parent item |
|
266 @type QTreeWidget or QTreeWidgetItem |
|
267 """ |
|
268 if isinstance(nodeOrFields, ast.AST): |
|
269 fields = [(key, val) for key, val in ast.iter_fields(nodeOrFields)] |
|
270 value = nodeOrFields.__class__.__name__ |
|
271 elif isinstance(nodeOrFields, list): |
|
272 fields = list(enumerate(nodeOrFields)) |
|
273 if len(nodeOrFields) == 0: |
|
274 value = "[]" |
|
275 else: |
|
276 value = "[...]" |
|
277 else: |
|
278 fields = [] |
|
279 value = repr(nodeOrFields) |
|
280 |
|
281 text = self.tr("{0}: {1}").format(name, value) |
|
282 itm = QTreeWidgetItem(parent, [text]) |
|
283 itm.setExpanded(True) |
|
284 |
|
285 if ( |
|
286 hasattr(nodeOrFields, "lineno") and |
|
287 hasattr(nodeOrFields, "col_offset") |
|
288 ): |
|
289 itm.setData(0, self.StartLineRole, nodeOrFields.lineno) |
|
290 itm.setData(0, self.StartIndexRole, nodeOrFields.col_offset) |
|
291 startStr = self.tr("{0},{1}").format( |
|
292 nodeOrFields.lineno, nodeOrFields.col_offset) |
|
293 endStr = "" |
|
294 |
|
295 if ( |
|
296 hasattr(nodeOrFields, "end_lineno") and |
|
297 hasattr(nodeOrFields, "end_col_offset") |
|
298 ): |
|
299 itm.setData(0, self.EndLineRole, nodeOrFields.end_lineno) |
|
300 itm.setData(0, self.EndIndexRole, |
|
301 nodeOrFields.end_col_offset) |
|
302 endStr = self.tr("{0},{1}").format( |
|
303 nodeOrFields.end_lineno, nodeOrFields.end_col_offset) |
|
304 else: |
|
305 itm.setData(0, self.EndLineRole, nodeOrFields.lineno) |
|
306 itm.setData(0, self.EndIndexRole, |
|
307 nodeOrFields.col_offset + 1) |
|
308 if endStr: |
|
309 rangeStr = self.tr("{0} - {1}").format(startStr, endStr) |
|
310 else: |
|
311 rangeStr = startStr |
|
312 |
|
313 itm.setText(1, rangeStr) |
|
314 |
|
315 for fieldName, fieldValue in fields: |
|
316 self.__populateNode(fieldName, fieldValue, itm) |
|
317 |
|
318 def __markTextRanges(self, tree, source): |
|
319 """ |
|
320 Private method to modify the AST nodes with end_lineno and |
|
321 end_col_offset information. |
|
322 |
|
323 Note: The modifications are only done for nodes containing lineno and |
|
324 col_offset attributes. |
|
325 |
|
326 @param tree reference to the AST to be modified |
|
327 @type ast.AST |
|
328 @param source source code the AST was created from |
|
329 @type str |
|
330 """ |
|
331 ASTTokens(source, tree=tree) |
|
332 for child in ast.walk(tree): |
|
333 if hasattr(child, 'last_token'): |
|
334 child.end_lineno, child.end_col_offset = child.last_token.end |
|
335 if hasattr(child, 'lineno'): |
|
336 # Fixes problems with some nodes like binop |
|
337 child.lineno, child.col_offset = child.first_token.start |
|
338 |
|
339 def __findClosestContainingNode(self, node, textRange): |
|
340 """ |
|
341 Private method to search for the AST node that contains a range |
|
342 closest. |
|
343 |
|
344 @param node AST node to start searching at |
|
345 @type ast.AST |
|
346 @param textRange tuple giving the start and end positions |
|
347 @type tuple of (int, int, int, int) |
|
348 @return best matching node |
|
349 @rtype ast.AST |
|
350 """ |
|
351 if textRange in [(-1, -1, -1, -1), (0, -1, 0, -1)]: |
|
352 # no valid range, i.e. no selection |
|
353 return None |
|
354 |
|
355 # first look among children |
|
356 for child in ast.iter_child_nodes(node): |
|
357 result = self.__findClosestContainingNode(child, textRange) |
|
358 if result is not None: |
|
359 return result |
|
360 |
|
361 # no suitable child was found |
|
362 if hasattr(node, "lineno") and self.__rangeContainsSmaller( |
|
363 (node.lineno, node.col_offset, node.end_lineno, |
|
364 node.end_col_offset), textRange): |
|
365 return node |
|
366 else: |
|
367 # nope |
|
368 return None |
|
369 |
|
370 def __findClosestContainingItem(self, itm, textRange): |
|
371 """ |
|
372 Private method to search for the tree item that contains a range |
|
373 closest. |
|
374 |
|
375 @param itm tree item to start searching at |
|
376 @type QTreeWidgetItem |
|
377 @param textRange tuple giving the start and end positions |
|
378 @type tuple of (int, int, int, int) |
|
379 @return best matching tree item |
|
380 @rtype QTreeWidgetItem |
|
381 """ |
|
382 if textRange in [(-1, -1, -1, -1), (0, -1, 0, -1)]: |
|
383 # no valid range, i.e. no selection |
|
384 return None |
|
385 |
|
386 lineno = itm.data(0, self.StartLineRole) |
|
387 if lineno is not None and not self.__rangeContainsSmallerOrEqual( |
|
388 (itm.data(0, self.StartLineRole), itm.data(0, self.StartIndexRole), |
|
389 itm.data(0, self.EndLineRole), itm.data(0, self.EndIndexRole)), |
|
390 textRange): |
|
391 return None |
|
392 |
|
393 # first look among children |
|
394 for index in range(itm.childCount()): |
|
395 child = itm.child(index) |
|
396 result = self.__findClosestContainingItem(child, textRange) |
|
397 if result is not None: |
|
398 return result |
|
399 |
|
400 # no suitable child was found |
|
401 lineno = itm.data(0, self.StartLineRole) |
|
402 if lineno is not None and self.__rangeContainsSmallerOrEqual( |
|
403 (itm.data(0, self.StartLineRole), itm.data(0, self.StartIndexRole), |
|
404 itm.data(0, self.EndLineRole), itm.data(0, self.EndIndexRole)), |
|
405 textRange): |
|
406 return itm |
|
407 else: |
|
408 # nope |
|
409 return None |
|
410 |
|
411 def __resizeColumns(self): |
|
412 """ |
|
413 Private method to resize the columns to suitable values. |
|
414 """ |
|
415 for col in range(self.__astWidget.columnCount()): |
|
416 self.__astWidget.resizeColumnToContents(col) |
|
417 |
|
418 rangeSize = self.__astWidget.columnWidth(1) + 10 |
|
419 # 10 px extra for the range |
|
420 nodeSize = max(400, self.__astWidget.viewport().width() - rangeSize) |
|
421 self.__astWidget.setColumnWidth(0, nodeSize) |
|
422 self.__astWidget.setColumnWidth(1, rangeSize) |
|
423 |
|
424 def resizeEvent(self, evt): |
|
425 """ |
|
426 Protected method to handle resize events. |
|
427 |
|
428 @param evt resize event |
|
429 @type QResizeEvent |
|
430 """ |
|
431 # just adjust the sizes of the columns |
|
432 self.__resizeColumns() |
|
433 |
|
434 def __rangeContainsSmaller(self, first, second): |
|
435 """ |
|
436 Private method to check, if second is contained in first. |
|
437 |
|
438 @param first text range to check against |
|
439 @type tuple of (int, int, int, int) |
|
440 @param second text range to check for |
|
441 @type tuple of (int, int, int, int) |
|
442 @return flag indicating second is contained in first |
|
443 @rtype bool |
|
444 """ |
|
445 firstStart = first[:2] |
|
446 firstEnd = first[2:] |
|
447 secondStart = second[:2] |
|
448 secondEnd = second[2:] |
|
449 |
|
450 return ( |
|
451 (firstStart < secondStart and firstEnd > secondEnd) or |
|
452 (firstStart == secondStart and firstEnd > secondEnd) or |
|
453 (firstStart < secondStart and firstEnd == secondEnd) |
|
454 ) |
|
455 |
|
456 def __rangeContainsSmallerOrEqual(self, first, second): |
|
457 """ |
|
458 Private method to check, if second is contained in or equal to first. |
|
459 |
|
460 @param first text range to check against |
|
461 @type tuple of (int, int, int, int) |
|
462 @param second text range to check for |
|
463 @type tuple of (int, int, int, int) |
|
464 @return flag indicating second is contained in or equal to first |
|
465 @rtype bool |
|
466 """ |
|
467 return first == second or self.__rangeContainsSmaller(first, second) |
|
468 |
|
469 def __clearSelection(self): |
|
470 """ |
|
471 Private method to clear all selected items. |
|
472 """ |
|
473 for itm in self.__astWidget.selectedItems(): |
|
474 itm.setSelected(False) |
|
475 |
|
476 def __selectItemForEditorSelection(self): |
|
477 """ |
|
478 Private slot to select the item corresponding to an editor selection. |
|
479 """ |
|
480 # step 1: clear all selected items |
|
481 self.__clearSelection() |
|
482 |
|
483 # step 2: retrieve the editor selection |
|
484 selection = self.__editor.getSelection() |
|
485 # make the line numbers 1-based |
|
486 selection = (selection[0] + 1, selection[1], |
|
487 selection[2] + 1, selection[3]) |
|
488 |
|
489 # step 3: search the corresponding item, scroll to it and select it |
|
490 itm = self.__findClosestContainingItem( |
|
491 self.__astWidget.topLevelItem(0), selection) |
|
492 if itm: |
|
493 self.__astWidget.scrollToItem( |
|
494 itm, QAbstractItemView.ScrollHint.PositionAtCenter) |
|
495 itm.setSelected(True) |
|
496 |
|
497 def __grabFocus(self): |
|
498 """ |
|
499 Private method to grab the input focus. |
|
500 """ |
|
501 self.__astWidget.setFocus(Qt.FocusReason.OtherFocusReason) |
|
502 |
|
503 @pyqtSlot(QTreeWidgetItem, int) |
|
504 def __astItemClicked(self, itm, column): |
|
505 """ |
|
506 Private slot handling a user click on an AST node item. |
|
507 |
|
508 @param itm reference to the clicked item |
|
509 @type QTreeWidgetItem |
|
510 @param column column number of the click |
|
511 @type int |
|
512 """ |
|
513 self.__editor.clearAllHighlights() |
|
514 |
|
515 if itm is not None: |
|
516 startLine = itm.data(0, self.StartLineRole) |
|
517 if startLine is not None: |
|
518 startIndex = itm.data(0, self.StartIndexRole) |
|
519 endLine = itm.data(0, self.EndLineRole) |
|
520 endIndex = itm.data(0, self.EndIndexRole) |
|
521 |
|
522 self.__editor.gotoLine(startLine, firstVisible=True, |
|
523 expand=True) |
|
524 self.__editor.setHighlight(startLine - 1, startIndex, |
|
525 endLine - 1, endIndex) |
|
526 |
|
527 @pyqtSlot() |
|
528 def preferencesChanged(self): |
|
529 """ |
|
530 Public slot handling changes of the AST viewer settings. |
|
531 """ |
|
532 self.__errorColor = QBrush( |
|
533 Preferences.getPython("ASTViewerErrorColor")) |