Skip to content

Repository files navigation

LeetTutor

A Python-Tutor-style, step-by-step execution visualizer for LeetCode solutions — runs entirely in your browser via PyScript/Pyodide, no server, no data leaves your machine.

Tests Pages

Live Demo →


What it does

Paste a Python solution and a test call, click Trace, and step through execution one line at a time — watching every variable, list, linked list, tree, dict, and set update in real time. Inspired by Philip Guo's Python Tutor, rebuilt specifically for LeetCode-style problems: ListNode/TreeNode helpers are pre-loaded so you can construct test inputs without boilerplate.

  • Code panel — highlights the line that just ran and the line about to run
  • Memory & Variables panel — shows the full call stack (Global scope down through every active frame, correctly separating recursive calls), renders lists as index tapes, 2-D arrays as grids, linked lists as node chains with arrows, trees as indented structures, dicts/sets as tag clouds. Only your own algorithm's variables ever appear — see the noise-filtering notes below.
  • Changed-variable highlighting — a variable that just changed value flashes and gets a "changed" tag, so you can track state evolution across loop iterations at a glance
  • Action tags — every step is labeled Loop / Branch / Assign / Update / Return / Call based on what the line does, shown as a colored badge next to the step counter and in the Trace History table
  • Result banner — once execution finishes, a dedicated green banner shows exactly what your function returned, rendered with the same visualizers (so a returned linked list still shows as a node chain, not a memory address)
  • Complexity tab — a static-analysis estimate of your code's time and space complexity (loop nesting, recursion shape, sort/memoization detection, hidden O(n) list-membership checks), with an expandable "Why?" reasoning list for each verdict. Runs entirely client-side via Python's own ast module — no LLM call, no server round-trip.
  • Trace History panel — a spreadsheet-style table of every step (line, action, frame, variables); click any row to jump straight there
  • Player controls — step forward/back, play/pause at adjustable speed, reset, with a slim progress bar tracking your position

The standalone page is the primary product — paste-and-go, nothing to install. A companion Chrome extension (below) is a thin convenience layer that auto-imports code from the LeetCode editor.


Running locally

git clone https://github.com/vkDemon1/leettutor.git
cd leettutor
python -m http.server 8000

Open http://localhost:8000.


Chrome Extension

The extension scrapes your current solution directly from LeetCode's Monaco editor and opens LeetTutor with it pre-loaded — no copy-paste.

How it works

  1. You click the extension's toolbar icon on a leetcode.com/problems/* page.
  2. A content script reads the editor's exact contents via LeetCode's own monaco object (not by scraping visible DOM text, which would miss off-screen lines).
  3. The code is base64-encoded into a URL and opened in a new tab: https://vkDemon1.github.io/leettutor/?code=<base64>&call=<base64>
  4. LeetTutor reads those URL parameters on load and runs the trace automatically.

This URL-param handoff is deliberately simpler than a postMessage/iframe integration — it works cross-origin with no coupling, degrades gracefully (worst case you land on an empty LeetTutor tab), and the resulting link is itself shareable.

Installing (unpacked, for now)

  1. Go to chrome://extensions, enable Developer mode.
  2. Click Load unpacked, select the extension/ folder in this repo.
  3. Open any LeetCode problem, write/select a Python3 solution, click the extension icon.

Known limitation

LeetCode's DOM and internal APIs change periodically; the extension's Monaco-based extraction is best-effort. inject.js polls for window.monaco for up to 3 seconds (Monaco can initialize after the content script first runs), and falls back to scraping visible DOM text only if that never appears — worth knowing because that DOM fallback only sees whatever lines are currently scrolled into view, so a long solution can come back truncated. If extraction ever returns empty or partial, just paste your code into the page manually — that path always works.

Partial/truncated code still traces. Whatever the source — a flaky extraction, or you mid-typing a solution — if the pasted code has a trailing syntax error, the tracer no longer refuses to run. It finds the longest prefix that parses cleanly, traces that, and shows a banner telling you how many lines were included versus dropped. A syntax error in the middle of otherwise-valid code (a genuine typo, not a truncation) still reports normally, since trimming only removes from the end.


Complexity analysis — how it works, and what it can't do

complexity_analyzer.py walks the target function's AST and applies the same pattern-matching an experienced reviewer does at a glance:

  • Loop nesting depth → polynomial time (O(n), O(n^2), ...)
  • while loops that shrink their range (hi = mid - 1, x //= 2) → O(log n)
  • Recursion, distinguishing:
    • genuinely overlapping subproblems with no memoization (fib(n-1) + fib(n-2)) → O(2^n)
    • the same branching shape applied to a tree/list (maxDepth(root.left), maxDepth(root.right)) → O(n), because each node is visited exactly once even though there are two call sites — this is the single easiest mistake for a naive "count the recursive calls" heuristic to make, so it gets a dedicated check
    • divide-and-conquer (mergeSort(arr[:mid])) → O(n log n)
    • binary-search-style (search(nums, target, mid+1, hi)) → O(log n), correctly recognizing that only one of the two call sites ever runs per invocation (an early-return "guard clause" is treated as an implicit else)
    • memoized recursion (@lru_cache, a memo/cache dict) → O(n)-ish, flagged as lower confidence since the real bound depends on the state space
  • sort()/sorted() → floors the estimate at O(n log n)
  • Space: data structures whose size scales with the input (dp = [0]*n, [[0]*n for _ in range(n)], a dict/list grown inside a loop) plus recursion call-stack depth
  • A specific warning for the classic accidental-quadratic bug: an in check against something that looks like a list (not a dict/set) inside a loop

This is a heuristic, not a prover. Determining exact asymptotic complexity for arbitrary code is undecidable in general — the analyzer can be fooled by unusual control flow, complexity hidden inside a call to a different helper function (only the target function's own body is analyzed), or generator/itertools-based tricks. Every verdict ships with its reasoning so you can sanity-check it yourself rather than take it on faith; treat it the way you'd treat a linter, not a test suite.

Running the test suite

pip install pytest
pytest tests/ -v

57 tests cover: the serializer (cycle detection, tuples, sets, long linked lists), noise filtering (typing.* exclusion, class-body frame suppression), the call stack, the result-capture step, the tracer itself (error handling mid-execution, to_list_node/to_tree_node helpers, the preamble line-offset invariant, partial-code recovery), and the complexity analyzer (every pattern described above, including the tree-vs-Fibonacci distinction).


Key engineering notes — bugs found and fixed

Building this surfaced several real bugs in the original tracer design, all covered by regression tests now:

  1. Runtime errors were silently swallowed. The original UI only checked steps[0].error, which only catches errors before any line executes (e.g. a syntax error). Any runtime error (IndexError, TypeError — the common case) landed at the end of the steps array and was never surfaced. Fixed: every step carries its own error/error_type fields, checked directly at render time.
  2. A deep exec() namespace bug meant to_list_node/to_tree_node never worked. exec(code, globals(), {}) uses two different dicts for globals and locals; nested helper functions resolve free variables via __globals__, not the locals dict, so ListNode/TreeNode (stored in the locals dict) were invisible to to_list_node/to_tree_node — a NameError on every single list/tree problem. Fixed by using one shared namespace dict for both globals and locals.
  3. Standard-library frames leaked into the trace. sys.settrace fires for every Python frame, including ones triggered inside import heapq/import collections during preamble execution. The tracer now filters to co_filename == '<string>' so only user code is recorded.
  4. Long linked lists were truncated. The serializer used a flat recursion-depth cutoff as a cycle guard, which falsely truncated any list longer than ~10 nodes. Replaced with proper object-id-based cycle detection (a visited set), so length is no longer conflated with cycles.
  5. Tuples and sets fell through to str() instead of being rendered as structured data. Both now serialize as tagged objects the UI renders properly.
  6. Fragile hardcoded line-offset. The UI used to hardcode -29 in two places to convert raw trace line numbers to user-visible ones, coupled to an exact preamble line count that lived in a different file. Now PREAMBLE_LINES is computed once from the actual preamble string and exposed to JS as window.TRACER_PREAMBLE_LINES, so editing the preamble can never silently desync line highlighting again.
  7. from typing import * and stdlib imports polluted every single variables panel. Because the preamble does from typing import * plus import collections/math/heapq/bisect, and the exec namespace fix above (point 2) intentionally shares one dict for globals/locals, the module-level frame's f_locals contained 40+ irrelevant names (Annotated, ParamSpec, TypeVarTuple, <module 'heapq' ...>, <class 'ListNode'>, and so on) alongside the learner's actual variables. Fixed by computing _PREAMBLE_NAMES once (by literally executing the preamble in isolation and recording what it binds) and excluding every one of those names, plus any module/class/function object, from every frame at render time — automatically correct regardless of Python version, since typing's exports differ release to release.
  8. Transient class-body frames leaked in too. Executing class Solution: briefly runs its own frame (to define the methods), which used to show up as an empty, confusing "Solution" card in the memory panel. Declaration-only lines (def/class headers with no bound state yet) are now filtered at every nesting level, not just module scope.
  9. No visibility into the call stack for recursive problems. The original design only ever showed the single innermost frame, so a recursive function like maxDepth gave no sense of the stack building up. The tracer now walks frame.f_back at every step to report the full active call stack (Global → outer calls → current frame), rendered as stacked cards with the currently-executing frame highlighted — closer to how Python Tutor itself visualizes recursion.
  10. No signal for what a step actually accomplishes. Added a zero-dependency line classifier (loop/branch/assign/update/return/call) surfaced as a colored badge, plus diff-based "changed" highlighting on any variable whose value differs from its last appearance in that same frame, plus an explicit result banner (the tracer doesn't get a real trace event after the final line, since sys.settrace fires before each line executes and the program then just ends — so the result is captured directly from the namespace after exec() returns and appended as its own step).
  11. Truncated code refused to run at all. Any trailing syntax error — from a flaky extraction or the learner mid-typing — made exec() fail before a single line executed, since Python has to compile a whole block before running any of it. The tracer now finds the longest prefix of the code that parses cleanly, traces that, and reports how much was dropped, instead of an all-or-nothing failure. (extract_and_trace()'s return shape changed from a bare steps array to {"steps": [...], "truncated": {...} | null} to carry this.)

Project structure

leettutor/
├── index.html                  # Standalone visualizer (primary product)
├── python_tracer.py            # Tracer engine + Pyodide FFI bindings
├── complexity_analyzer.py      # Static AST-based time/space complexity estimator
├── extension/                  # Chrome extension (Manifest V3)
│   ├── manifest.json
│   ├── background.js           # Toolbar click handler → builds the handoff URL
│   ├── content.js              # Extracts code from the LeetCode page
│   └── inject.js               # Page-context script that reads monaco directly
├── tests/
│   ├── test_tracer.py          # 30 tests covering the tracer + serializer + recovery
│   └── test_complexity.py      # 25 tests covering the complexity heuristics
└── .github/workflows/
    ├── test.yml                # CI: run tests on every push/PR
    └── deploy.yml              # CI: deploy index.html to GitHub Pages

Security note

execute_and_trace() runs your code via exec(). This is by design for a personal, local, single-user tool. It is not hardened for hosting as a public multi-tenant service — don't repurpose the tracer backend that way without adding a real sandbox.


Credits

Inspired by Python Tutor by Philip Guo — a fantastic tool for building programming intuition. LeetTutor narrows that idea specifically to LeetCode-style problems (pre-loaded ListNode/TreeNode helpers, LeetCode DOM integration) rather than trying to be a general-purpose replacement.

License

MIT © 2025 vkDemon1

About

Step-by-step browser-based execution visualizer & static complexity analyzer for LeetCode Python solutions. 100% client-side via Pyodide.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages