Skip to content

Commit d842aca

Browse files
Add an AST browser extension to IDLE
The ASTBrowser extension adds an AST Browser command to the Tools menu. 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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 379033e commit d842aca

10 files changed

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

Lib/idlelib/config-extensions.def

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ enable= 1
5555
[TokenBrowser_cfgBindings]
5656
token-browser=
5757

58+
# A browser for the abstract syntax tree of the editor, from the Tools menu.
59+
[ASTBrowser]
60+
enable= 1
61+
[ASTBrowser_cfgBindings]
62+
ast-browser=
63+
5864
# A fake extension for testing and example purposes. When enabled and
5965
# invoked, inserts or deletes z-text at beginning of every line.
6066
[ZzDummy]

Lib/idlelib/editor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,7 @@ def get_standard_extension_names(self):
11551155

11561156
extfiles = { # Map built-in config-extension section names to file names.
11571157
'TokenBrowser': 'tokenbrowser',
1158+
'ASTBrowser': 'astbrowser',
11581159
'ZzDummy': 'zzdummy',
11591160
}
11601161

0 commit comments

Comments
 (0)