src/eric7/UI/PythonAstViewer.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
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"))

eric ide

mercurial