|
| 1 | +"""An AST browser for IDLE. |
| 2 | +
|
| 3 | +The Browse menu's "AST Browser" command (see open() below) opens a window |
| 4 | +showing the abstract syntax tree of the editor content (or, in the Shell, |
| 5 | +the current input), or of the selection if there is one. Selecting a node |
| 6 | +highlights the matching region in the editor and moves the editor cursor |
| 7 | +there; selecting text or moving the cursor in the editor selects the |
| 8 | +innermost matching node. Double-clicking a node hides the browser (as |
| 9 | +does Escape), revealing the editor at the node. |
| 10 | +""" |
| 11 | +import ast |
| 12 | + |
| 13 | +from tkinter import Toplevel, TclError |
| 14 | +from tkinter import TOP, BOTTOM, LEFT, RIGHT, X, Y, BOTH, W, END, VERTICAL |
| 15 | +from tkinter import ttk |
| 16 | + |
| 17 | +from idlelib.config import idleConf |
| 18 | + |
| 19 | +# The editor tag that highlights the source of the selected nodes. |
| 20 | +TAG = "ASTBROWSER" |
| 21 | + |
| 22 | + |
| 23 | +def open(editwin): |
| 24 | + "Open the AST browser for editwin, reusing one already open." |
| 25 | + window = getattr(editwin, "ast_browser", None) |
| 26 | + if window is not None and window.winfo_exists(): |
| 27 | + window.refresh() |
| 28 | + else: |
| 29 | + editwin.ast_browser = ASTBrowserWindow(editwin.top, editwin.text) |
| 30 | + |
| 31 | + |
| 32 | +class ASTBrowserWindow(Toplevel): |
| 33 | + "Show the abstract syntax tree of a Text widget's content or selection." |
| 34 | + |
| 35 | + def __init__(self, parent, text, *, _htest=False, _utest=False): |
| 36 | + """Create the AST browser. |
| 37 | +
|
| 38 | + parent - the master widget of this window. |
| 39 | + text - the editor Text widget to browse and drive. |
| 40 | + _htest - bool; change box location when running htest. |
| 41 | + _utest - bool; don't wait for user interaction when unit testing. |
| 42 | + """ |
| 43 | + super().__init__(parent) |
| 44 | + self.text = text |
| 45 | + self.base = (1, 0) # Editor index of the parsed region's start. |
| 46 | + self.source_lines = [] # Lines of the parsed source (for byte->char). |
| 47 | + self.ranges = {} # Tree item id -> (start index, end index). |
| 48 | + self.focused = False # Whether the browser currently has the focus. |
| 49 | + self.title("AST Browser") |
| 50 | + self.protocol("WM_DELETE_WINDOW", self.hide) |
| 51 | + self.bind("<Escape>", self.hide) |
| 52 | + x = parent.winfo_rootx() + 20 |
| 53 | + y = parent.winfo_rooty() + (100 if _htest else 20) |
| 54 | + self.geometry(f"640x480+{x}+{y}") |
| 55 | + self.minsize(400, 300) |
| 56 | + |
| 57 | + self.create_widgets() |
| 58 | + self.configure_tag() |
| 59 | + self.populate() |
| 60 | + # Follow the editor and select the matching node. <<Selection>> covers |
| 61 | + # selection changes by keyboard or mouse (a generic <KeyRelease> is |
| 62 | + # shadowed by IDLE's specific key bindings); the release events cover |
| 63 | + # plain cursor moves that leave no selection. These bindings live as |
| 64 | + # long as the editor Text and are torn down together with it (and with |
| 65 | + # this child window), so there is nothing to unbind. |
| 66 | + text.bind("<<Selection>>", self.sync_from_editor, add="+") |
| 67 | + text.bind("<KeyRelease>", self.sync_from_editor, add="+") |
| 68 | + text.bind("<ButtonRelease-1>", self.sync_from_editor, add="+") |
| 69 | + if not _utest: |
| 70 | + self.deiconify() |
| 71 | + |
| 72 | + def create_widgets(self): |
| 73 | + bar = ttk.Frame(self, padding=(6, 6, 6, 0)) |
| 74 | + bar.pack(side=TOP, fill=X) |
| 75 | + ttk.Button(bar, text="Refresh", command=self.populate).pack(side=LEFT) |
| 76 | + |
| 77 | + self.status = ttk.Label(self, anchor=W, relief="sunken", padding=3) |
| 78 | + self.status.pack(side=BOTTOM, fill=X) |
| 79 | + |
| 80 | + frame = ttk.Frame(self, padding=6) |
| 81 | + frame.pack(side=TOP, fill=BOTH, expand=True) |
| 82 | + self.tree = ttk.Treeview(frame, show="tree", selectmode="extended") |
| 83 | + vbar = ttk.Scrollbar(frame, orient=VERTICAL, command=self.tree.yview) |
| 84 | + self.tree.configure(yscrollcommand=vbar.set) |
| 85 | + vbar.pack(side=RIGHT, fill=Y) |
| 86 | + self.tree.pack(side=LEFT, fill=BOTH, expand=True) |
| 87 | + self.tree.bind("<<TreeviewSelect>>", self.select_nodes) |
| 88 | + self.tree.bind("<Double-Button-1>", self.goto_node) |
| 89 | + # The highlight is shown only while the browser has the focus. |
| 90 | + self.bind("<FocusIn>", self.on_focus_in) |
| 91 | + self.bind("<FocusOut>", self.on_focus_out) |
| 92 | + |
| 93 | + def configure_tag(self): |
| 94 | + "Give the highlight tag the theme's 'hit' colors." |
| 95 | + try: |
| 96 | + colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'hit') |
| 97 | + except Exception: |
| 98 | + colors = {'foreground': '#000000', 'background': '#ffff80'} |
| 99 | + self.text.tag_configure(TAG, **colors) |
| 100 | + |
| 101 | + def editor_selection(self): |
| 102 | + "Return the editor's (first, last) selection, or ('', '') if none." |
| 103 | + try: |
| 104 | + # A plain Text raises without a selection; the IDLE editor |
| 105 | + # returns an empty string instead. |
| 106 | + return self.text.index("sel.first"), self.text.index("sel.last") |
| 107 | + except TclError: |
| 108 | + return "", "" |
| 109 | + |
| 110 | + def editor_index(self, lineno, col): |
| 111 | + "Map an AST (lineno, byte col) to an editor index, honoring the base." |
| 112 | + if lineno <= len(self.source_lines): |
| 113 | + # col_offset is a UTF-8 byte offset; convert it to a character one. |
| 114 | + col = len(self.source_lines[lineno - 1].encode()[:col] |
| 115 | + .decode(errors="replace")) |
| 116 | + base_row, base_col = self.base |
| 117 | + if lineno == 1: |
| 118 | + col += base_col |
| 119 | + return f"{base_row + lineno - 1}.{col}" |
| 120 | + |
| 121 | + def node_range(self, node): |
| 122 | + "Return the (start, end) editor indices of a node, or None." |
| 123 | + if getattr(node, "lineno", None) is None or node.end_lineno is None: |
| 124 | + return None |
| 125 | + return (self.editor_index(node.lineno, node.col_offset), |
| 126 | + self.editor_index(node.end_lineno, node.end_col_offset)) |
| 127 | + |
| 128 | + def populate(self, event=None): |
| 129 | + "Parse the content (or selection) and fill the tree." |
| 130 | + self.hide_highlight() |
| 131 | + self.tree.delete(*self.tree.get_children()) |
| 132 | + self.ranges.clear() |
| 133 | + text = self.text |
| 134 | + first, last = self.editor_selection() |
| 135 | + if first and last: |
| 136 | + scope = "selection" |
| 137 | + else: |
| 138 | + last = text.index("end-1c") |
| 139 | + # In the Shell, browse just the current input, which starts at the |
| 140 | + # "iomark"; a plain editor has no such mark. IDLE's editor returns |
| 141 | + # '' for a missing mark, while a plain Text raises TclError. |
| 142 | + try: |
| 143 | + first = text.index("iomark") |
| 144 | + except TclError: |
| 145 | + first = "" |
| 146 | + if first: |
| 147 | + scope = "input" |
| 148 | + else: |
| 149 | + first, scope = "1.0", "text" |
| 150 | + self.base = tuple(int(i) for i in first.split(".")) |
| 151 | + source = text.get(first, last) |
| 152 | + self.source_lines = source.splitlines() |
| 153 | + error = count = None |
| 154 | + try: |
| 155 | + tree = ast.parse(source) |
| 156 | + except SyntaxError as exc: |
| 157 | + error = exc.msg |
| 158 | + else: |
| 159 | + count = self.add_node("", "", tree) |
| 160 | + status = f"{count or 0} nodes in {scope}" |
| 161 | + if error: |
| 162 | + status += f" — incomplete: {error}" |
| 163 | + self.status.configure(text=status) |
| 164 | + self.sync_from_editor() |
| 165 | + |
| 166 | + def add_node(self, parent_item, field, node): |
| 167 | + "Insert a node and its descendants; return the number of nodes added." |
| 168 | + inline = [] # Fields shown in this row: 'name=value'. |
| 169 | + children = [] # Fields shown as child rows: (label, node). |
| 170 | + for name, value in ast.iter_fields(node): |
| 171 | + if isinstance(value, ast.AST): |
| 172 | + if value._fields: |
| 173 | + children.append((name, value)) |
| 174 | + else: # An operator or context, e.g. Add, Load. |
| 175 | + inline.append(f"{name}={type(value).__name__}") |
| 176 | + elif isinstance(value, list): |
| 177 | + nodes = [(f"{name}[{i}]", elt) for i, elt in enumerate(value) |
| 178 | + if isinstance(elt, ast.AST)] |
| 179 | + if nodes: |
| 180 | + children += nodes |
| 181 | + elif value: # A non-empty list of scalars; drop empty ones. |
| 182 | + inline.append(f"{name}={value!r}") |
| 183 | + elif value is not None or name == "value": # Keep the None literal. |
| 184 | + inline.append(f"{name}={value!r}") |
| 185 | + |
| 186 | + label = type(node).__name__ |
| 187 | + if inline: |
| 188 | + label += "(" + ", ".join(inline) + ")" |
| 189 | + if field: |
| 190 | + label = f"{field}: {label}" |
| 191 | + item = self.tree.insert(parent_item, END, text=label, open=True) |
| 192 | + if rng := self.node_range(node): |
| 193 | + self.ranges[item] = rng |
| 194 | + |
| 195 | + count = 1 |
| 196 | + for name, child in children: |
| 197 | + count += self.add_node(item, name, child) |
| 198 | + return count |
| 199 | + |
| 200 | + def refresh(self): |
| 201 | + "Re-parse the current range and bring the browser to the front." |
| 202 | + self.populate() |
| 203 | + self.deiconify() |
| 204 | + self.lift() |
| 205 | + self.focus_set() |
| 206 | + |
| 207 | + def sync_from_editor(self, event=None): |
| 208 | + "Select the innermost node matching the editor's selection or cursor." |
| 209 | + first, last = self.editor_selection() |
| 210 | + if not (first and last): |
| 211 | + first = last = self.text.index("insert") |
| 212 | + self.select_rows(self.enclosing_node(first, last)) |
| 213 | + |
| 214 | + def enclosing_node(self, first, last): |
| 215 | + "Return [item] of the smallest node covering [first, last], or []." |
| 216 | + best = None |
| 217 | + for item, (start, end) in self.ranges.items(): |
| 218 | + if (self.text.compare(start, "<=", first) |
| 219 | + and self.text.compare(last, "<=", end)): |
| 220 | + # Covering nodes are nested; keep the tightest (deepest) one. |
| 221 | + if best is None or (self.text.compare(start, ">=", best[1]) |
| 222 | + and self.text.compare(end, "<=", best[2])): |
| 223 | + best = (item, start, end) |
| 224 | + return [best[0]] if best else [] |
| 225 | + |
| 226 | + def select_rows(self, items): |
| 227 | + "Select the given tree rows and reveal the first." |
| 228 | + if items: |
| 229 | + self.tree.selection_set(items) |
| 230 | + self.tree.focus(items[0]) |
| 231 | + self.tree.see(items[0]) |
| 232 | + |
| 233 | + def select_nodes(self, event=None): |
| 234 | + "Highlight the selected nodes and, while focused, follow with the cursor." |
| 235 | + self.show_highlight(see=True) |
| 236 | + # Move the editor cursor only when the browser drives the selection |
| 237 | + # (it has the focus). When the editor drives it, the browser is not |
| 238 | + # focused, so the cursor is left alone and there is no feedback loop. |
| 239 | + if self.focused: |
| 240 | + self.move_cursor() |
| 241 | + |
| 242 | + def show_highlight(self, see=False): |
| 243 | + "Highlight the selected nodes' source while the browser has focus." |
| 244 | + if not self.focused: # Keep the editor clean while it is in use. |
| 245 | + return |
| 246 | + text = self.text |
| 247 | + self.hide_highlight() |
| 248 | + first = None |
| 249 | + for item in self.tree.selection(): |
| 250 | + rng = self.ranges.get(item) |
| 251 | + if rng and rng[0] != rng[1]: # Skip nodes with no source span. |
| 252 | + text.tag_add(TAG, *rng) |
| 253 | + if first is None: |
| 254 | + first = rng[0] |
| 255 | + text.tag_raise(TAG) |
| 256 | + if see and first is not None: |
| 257 | + text.see(first) |
| 258 | + |
| 259 | + def on_focus_in(self, event=None): |
| 260 | + "Restore the highlight when the browser regains focus." |
| 261 | + self.focused = True |
| 262 | + self.show_highlight() |
| 263 | + |
| 264 | + def on_focus_out(self, event=None): |
| 265 | + "Hide the highlight while the editor (or another window) has focus." |
| 266 | + self.focused = False |
| 267 | + self.hide_highlight() |
| 268 | + |
| 269 | + def goto_node(self, event=None): |
| 270 | + "Move the cursor to the double-clicked node and hide the browser." |
| 271 | + self.move_cursor(self.tree.identify_row(event.y)) |
| 272 | + self.hide() |
| 273 | + return "break" # Suppress the default double-click handling. |
| 274 | + |
| 275 | + def move_cursor(self, item=None): |
| 276 | + "Move the editor cursor to a node (the first selected row by default)." |
| 277 | + if item is None: |
| 278 | + selection = self.tree.selection() |
| 279 | + item = selection[0] if selection else None |
| 280 | + rng = self.ranges.get(item) |
| 281 | + if rng: |
| 282 | + self.text.mark_set("insert", rng[0]) |
| 283 | + self.text.see(rng[0]) |
| 284 | + |
| 285 | + def hide(self, event=None): |
| 286 | + """Withdraw the browser, revealing the editor and giving it focus. |
| 287 | +
|
| 288 | + Hiding our own window sidesteps the window manager's focus-stealing |
| 289 | + prevention, which blocks a background editor window from being raised. |
| 290 | + """ |
| 291 | + self.hide_highlight() |
| 292 | + self.withdraw() |
| 293 | + self.text.focus_set() |
| 294 | + |
| 295 | + def hide_highlight(self, event=None): |
| 296 | + try: |
| 297 | + self.text.tag_remove(TAG, "1.0", "end") |
| 298 | + except TclError: # The editor may already be gone. |
| 299 | + pass |
| 300 | + |
| 301 | + |
| 302 | +def _ast_browser(parent): # htest # |
| 303 | + "Set up a sample editor Text and open an AST browser on it." |
| 304 | + from tkinter import Text |
| 305 | + top = Toplevel(parent) |
| 306 | + top.title("Sample editor") |
| 307 | + text = Text(top, width=40, height=8) |
| 308 | + text.insert("1.0", "import sys\n\ndef f(x):\n return x + 1 # add one\n") |
| 309 | + text.pack(fill=BOTH, expand=True) |
| 310 | + return ASTBrowserWindow(top, text, _htest=True) |
| 311 | + |
| 312 | + |
| 313 | +if __name__ == "__main__": |
| 314 | + from unittest import main |
| 315 | + main('idlelib.idle_test.test_astbrowser', verbosity=2, exit=False) |
| 316 | + |
| 317 | + from idlelib.idle_test.htest import run |
| 318 | + run(_ast_browser) |
0 commit comments