Skip to content

Commit eacd68e

Browse files
gh-152942: Add an AST browser to IDLE
Add an AST Browser command to the Browse menu (Shell and editor). It opens a window showing the abstract syntax tree of the editor content (or, in the Shell, the current input), or of the selection if there is one. Selecting a node highlights the matching region in the editor and, while the browser has focus, moves the editor cursor there; selecting text or moving the cursor in the editor selects the innermost enclosing node. Double-clicking a node (or pressing Escape) hides the browser, revealing the editor at the node. It shares the window skeleton and editor-sync mechanism with the token browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c1f145f commit eacd68e

8 files changed

Lines changed: 595 additions & 0 deletions

File tree

Doc/library/idle.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,17 @@ Token Browser
310310
Double-click a row, or press :kbd:`Escape`,
311311
to hide the browser and return to the editor at the token.
312312

313+
AST Browser
314+
Open a window showing the abstract syntax tree of the editor content
315+
(or, in the Shell, the current input),
316+
or of the selection if there is one.
317+
Selecting a node highlights the matching region in the editor
318+
and moves the cursor there;
319+
selecting text or moving the cursor in the editor
320+
selects the innermost enclosing node.
321+
Double-click a node, or press :kbd:`Escape`,
322+
to hide the browser and return to the editor at the node.
323+
313324
Options menu (Shell and Editor)
314325
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
315326

Lib/idlelib/News3.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Released on 2026-10-01
44
=========================
55

66

7+
gh-152942: Add an AST Browser to IDLE, opened from the Browse menu. It
8+
shows the abstract syntax tree of the editor content, the Shell input,
9+
or the selection. Selecting a node highlights the matching region in
10+
the editor, and selecting text in the editor selects the innermost
11+
enclosing node. Patch by Serhiy Storchaka and Claude Code.
12+
713
gh-152941: Add a Token Browser to IDLE, opened from the new Browse menu.
814
It lists the Python tokens of the editor content, the Shell input, or
915
the selection, with token type names colored as by `python -m tokenize`.

Lib/idlelib/astbrowser.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
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)

Lib/idlelib/editor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
167167
text.bind("<<open-class-browser>>", self.open_module_browser)
168168
text.bind("<<open-path-browser>>", self.open_path_browser)
169169
text.bind("<<open-token-browser>>", self.open_token_browser)
170+
text.bind("<<open-ast-browser>>", self.open_ast_browser)
170171
text.bind("<<open-turtle-demo>>", self.open_turtle_demo)
171172

172173
self.set_status_bar()
@@ -747,6 +748,11 @@ def open_token_browser(self, event=None):
747748
tokenbrowser.open(self)
748749
return "break"
749750

751+
def open_ast_browser(self, event=None):
752+
from idlelib import astbrowser
753+
astbrowser.open(self)
754+
return "break"
755+
750756
def open_turtle_demo(self, event = None):
751757
import subprocess
752758

Lib/idlelib/idle_test/htest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@
8989
"start of that token and the editor gets focus."
9090
}
9191

92+
_ast_browser_spec = {
93+
'file': 'astbrowser',
94+
'kwds': {},
95+
'msg': "Expand nodes in the AST tree and verify the matching source\n"
96+
"regions are highlighted in the sample editor above. Select\n"
97+
"text in the editor and verify the enclosing node is selected.\n"
98+
"Double-click a node and verify the editor cursor jumps to it\n"
99+
"and the editor gets focus."
100+
}
101+
92102
# TODO implement ^\; adding '<Control-Key-\\>' to function does not work.
93103
_calltip_window_spec = {
94104
'file': 'calltip_w',

0 commit comments

Comments
 (0)