[llvm] [mlir] [mlir] Add mlir-opt-repl: interactive MLIR pass pipeline explorer and MCP server (PR #203796)
Maksim Levental via llvm-commits
llvm-commits at lists.llvm.org
Sun Jun 14 15:44:56 PDT 2026
https://github.com/makslevental updated https://github.com/llvm/llvm-project/pull/203796
>From 47014a6da15591f5dde8dc86719e0afcf225920d Mon Sep 17 00:00:00 2001
From: makslevental <m_levental at apple.com>
Date: Sun, 14 Jun 2026 15:01:23 -0700
Subject: [PATCH] [mlir] Add mlir-opt-repl: interactive MLIR pass pipeline
explorer and MCP server
A pip-installable Python package (mlir-opt-repl) that provides:
- An interactive terminal REPL for applying mlir-opt passes step by step,
with history navigation, rewind, and colored unified/side-by-side diffs.
- An MCP (Model Context Protocol) server for use as a Claude Code tool,
exposing the same functionality over JSON-RPC stdio.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply at anthropic.com>
---
.ci/all_requirements.txt | 150 +++++++++-
mlir/python/requirements.txt | 5 +
mlir/test/mlir-opt-repl/conftest.py | 84 ++++++
mlir/test/mlir-opt-repl/lit.local.cfg | 13 +
mlir/test/mlir-opt-repl/run-tests.test | 1 +
.../mlir-opt-repl/test_cli_and_edge_cases.py | 150 ++++++++++
mlir/test/mlir-opt-repl/test_mcp.py | 281 ++++++++++++++++++
mlir/test/mlir-opt-repl/test_repl.py | 203 +++++++++++++
mlir/tools/mlir-opt-repl/README.md | 134 +++++++++
mlir/tools/mlir-opt-repl/pyproject.toml | 37 +++
.../src/mlir_opt_repl/__init__.py | 3 +
.../src/mlir_opt_repl/__main__.py | 94 ++++++
.../mlir-opt-repl/src/mlir_opt_repl/diff.py | 91 ++++++
.../mlir-opt-repl/src/mlir_opt_repl/engine.py | 211 +++++++++++++
.../mlir-opt-repl/src/mlir_opt_repl/mcp.py | 201 +++++++++++++
.../mlir-opt-repl/src/mlir_opt_repl/render.py | 7 +
.../mlir-opt-repl/src/mlir_opt_repl/repl.py | 204 +++++++++++++
17 files changed, 1867 insertions(+), 2 deletions(-)
create mode 100644 mlir/test/mlir-opt-repl/conftest.py
create mode 100644 mlir/test/mlir-opt-repl/lit.local.cfg
create mode 100644 mlir/test/mlir-opt-repl/run-tests.test
create mode 100644 mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
create mode 100644 mlir/test/mlir-opt-repl/test_mcp.py
create mode 100644 mlir/test/mlir-opt-repl/test_repl.py
create mode 100644 mlir/tools/mlir-opt-repl/README.md
create mode 100644 mlir/tools/mlir-opt-repl/pyproject.toml
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py
diff --git a/.ci/all_requirements.txt b/.ci/all_requirements.txt
index 1ab2a81b1d1db..6bb95061d657d 100644
--- a/.ci/all_requirements.txt
+++ b/.ci/all_requirements.txt
@@ -181,6 +181,118 @@ charset-normalizer==3.4.3 \
--hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \
--hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9
# via requests
+click==8.4.1 \
+ --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \
+ --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96
+ # via -r mlir/python/requirements.txt
+coverage[toml]==7.14.1 \
+ --hash=sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86 \
+ --hash=sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd \
+ --hash=sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d \
+ --hash=sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5 \
+ --hash=sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42 \
+ --hash=sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de \
+ --hash=sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548 \
+ --hash=sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1 \
+ --hash=sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7 \
+ --hash=sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59 \
+ --hash=sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906 \
+ --hash=sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af \
+ --hash=sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1 \
+ --hash=sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d \
+ --hash=sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1 \
+ --hash=sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be \
+ --hash=sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02 \
+ --hash=sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42 \
+ --hash=sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129 \
+ --hash=sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e \
+ --hash=sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be \
+ --hash=sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e \
+ --hash=sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65 \
+ --hash=sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54 \
+ --hash=sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1 \
+ --hash=sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5 \
+ --hash=sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df \
+ --hash=sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47 \
+ --hash=sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f \
+ --hash=sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf \
+ --hash=sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37 \
+ --hash=sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4 \
+ --hash=sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f \
+ --hash=sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84 \
+ --hash=sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1 \
+ --hash=sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c \
+ --hash=sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8 \
+ --hash=sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e \
+ --hash=sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec \
+ --hash=sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d \
+ --hash=sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54 \
+ --hash=sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890 \
+ --hash=sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b \
+ --hash=sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d \
+ --hash=sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2 \
+ --hash=sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33 \
+ --hash=sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9 \
+ --hash=sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e \
+ --hash=sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 \
+ --hash=sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce \
+ --hash=sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247 \
+ --hash=sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901 \
+ --hash=sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36 \
+ --hash=sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69 \
+ --hash=sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416 \
+ --hash=sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5 \
+ --hash=sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500 \
+ --hash=sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad \
+ --hash=sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1 \
+ --hash=sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b \
+ --hash=sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b \
+ --hash=sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a \
+ --hash=sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e \
+ --hash=sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee \
+ --hash=sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07 \
+ --hash=sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a \
+ --hash=sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d \
+ --hash=sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c \
+ --hash=sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343 \
+ --hash=sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4 \
+ --hash=sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2 \
+ --hash=sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8 \
+ --hash=sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf \
+ --hash=sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb \
+ --hash=sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c \
+ --hash=sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff \
+ --hash=sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e \
+ --hash=sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550 \
+ --hash=sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860 \
+ --hash=sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793 \
+ --hash=sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f \
+ --hash=sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851 \
+ --hash=sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7 \
+ --hash=sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332 \
+ --hash=sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b \
+ --hash=sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2 \
+ --hash=sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d \
+ --hash=sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a \
+ --hash=sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef \
+ --hash=sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474 \
+ --hash=sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee \
+ --hash=sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43 \
+ --hash=sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034 \
+ --hash=sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3 \
+ --hash=sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c \
+ --hash=sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d \
+ --hash=sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7 \
+ --hash=sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e \
+ --hash=sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d \
+ --hash=sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4 \
+ --hash=sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9 \
+ --hash=sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52 \
+ --hash=sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a \
+ --hash=sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c \
+ --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \
+ --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c
+ # via pytest-cov
cryptography==46.0.3 \
--hash=sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217 \
--hash=sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d \
@@ -239,6 +351,10 @@ cryptography==46.0.3 \
# via
# -r lldb/test/requirements.txt
# pyjwt
+execnet==2.1.2 \
+ --hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \
+ --hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec
+ # via pytest-xdist
google-api-core==2.25.1 \
--hash=sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7 \
--hash=sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8
@@ -310,6 +426,10 @@ idna==3.10 \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
# via requests
+iniconfig==2.3.0 \
+ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ # via pytest
junitparser==3.2.0 \
--hash=sha256:b05e89c27e7b74b3c563a078d6e055d95cf397444f8f689b0ca616ebda0b3c65 \
--hash=sha256:e14fdc0a999edfc15889b637390e8ef6ca09a49532416d3bd562857d42d4b96d
@@ -404,11 +524,19 @@ numpy==2.1.2 \
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
- # via -r lldb/test/requirements.txt
+ # via
+ # -r lldb/test/requirements.txt
+ # pytest
pexpect==4.9.0 ; sys_platform != "win32" \
--hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \
--hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
# via -r lldb/test/requirements.txt
+pluggy==1.6.0 \
+ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+ # via
+ # pytest
+ # pytest-cov
proto-plus==1.26.1 \
--hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \
--hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012
@@ -461,6 +589,10 @@ pygithub==2.8.1 \
--hash=sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0 \
--hash=sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9
# via -r .ci/requirements.txt
+pygments==2.20.0 \
+ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+ # via pytest
pyjwt[crypto]==2.10.1 \
--hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \
--hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb
@@ -494,6 +626,21 @@ pynacl==1.6.0 \
--hash=sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb \
--hash=sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e
# via pygithub
+pytest==9.1.0 \
+ --hash=sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c \
+ --hash=sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32
+ # via
+ # -r mlir/python/requirements.txt
+ # pytest-cov
+ # pytest-xdist
+pytest-cov==7.1.0 \
+ --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
+ --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
+ # via -r mlir/python/requirements.txt
+pytest-xdist==3.8.0 \
+ --hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \
+ --hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1
+ # via -r mlir/python/requirements.txt
pyyaml==6.0.1 \
--hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \
--hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \
@@ -580,7 +727,6 @@ typing-extensions==4.15.0 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# -r mlir/python/requirements.txt
- # cryptography
# pygithub
urllib3==2.5.0 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
diff --git a/mlir/python/requirements.txt b/mlir/python/requirements.txt
index 538929e1bbfd2..65e430c3813f2 100644
--- a/mlir/python/requirements.txt
+++ b/mlir/python/requirements.txt
@@ -5,3 +5,8 @@ typing_extensions>=4.12.2
# RUN dependencies
numpy>=2.1.0, <=2.1.2
ml_dtypes>=0.5.0, <=0.6.0
+click>=8.2.1
+# TEST dependencies
+pytest>=9.0.3
+pytest-cov>=7.1.0
+pytest-xdist>=3.8.0
diff --git a/mlir/test/mlir-opt-repl/conftest.py b/mlir/test/mlir-opt-repl/conftest.py
new file mode 100644
index 0000000000000..39972267255a4
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/conftest.py
@@ -0,0 +1,84 @@
+import json
+import re
+import sys
+from contextlib import contextmanager
+from io import StringIO
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(
+ 0, str(Path(__file__).parent.parent.parent / "tools" / "mlir-opt-repl" / "src")
+)
+
+import mlir_opt_repl.engine as engine
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+SAMPLE_MLIR = "func.func @test(%arg0: f32, %arg1: f32) -> f32 { %0 = arith.addf %arg0, %arg1 : f32 return %0 : f32 }"
+INIT_MSG = {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}
+
+
+ at pytest.fixture(autouse=True)
+def reset_engine():
+ engine.current_ir = None
+ engine.ir_history = []
+ yield
+ engine.current_ir = None
+ engine.ir_history = []
+
+
+ at contextmanager
+def capture_stdio(input_text):
+ old_stdin, old_stdout = sys.stdin, sys.stdout
+ sys.stdin = StringIO(input_text)
+ sys.stdout = StringIO()
+ try:
+ yield sys.stdout
+ finally:
+ sys.stdin, sys.stdout = old_stdin, old_stdout
+
+
+def run_mcp(*messages):
+ input_text = ""
+ for m in messages:
+ text = json.dumps(m)
+ input_text += f"Content-Length: {len(text.encode())}\r\n\r\n{text}"
+
+ with capture_stdio(input_text) as stdout:
+ try:
+ mcp_main()
+ except Exception:
+ pass
+ return stdout.getvalue()
+
+
+def run_repl(input_text):
+ with capture_stdio(input_text) as stdout:
+ try:
+ interactive_main()
+ except (EOFError, SystemExit):
+ pass
+ return stdout.getvalue()
+
+
+def parse_responses(output):
+ responses = []
+ for m in re.finditer(r"Content-Length: (\d+)\r?\n\r?\n", output):
+ start = m.end()
+ length = int(m.group(1))
+ body = output[start : start + length]
+ responses.append(json.loads(body))
+ return responses
+
+
+def tool_call(id, name, arguments=None):
+ return {
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": "tools/call",
+ "params": {
+ "name": name,
+ "arguments": arguments or {},
+ },
+ }
diff --git a/mlir/test/mlir-opt-repl/lit.local.cfg b/mlir/test/mlir-opt-repl/lit.local.cfg
new file mode 100644
index 0000000000000..a911833dfd4ad
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/lit.local.cfg
@@ -0,0 +1,13 @@
+import os
+
+src_root = os.path.join(config.mlir_src_root, "tools", "mlir-opt-repl", "src")
+pyproject = os.path.join(config.mlir_src_root, "tools", "mlir-opt-repl", "pyproject.toml")
+config.environment["PYTHONPATH"] = src_root
+config.environment["MLIR_OPT"] = os.path.join(config.mlir_tools_dir, "mlir-opt")
+
+config.substitutions.insert(0, ("%pyproject", pyproject))
+
+config.suffixes = [".test"]
+
+if not config.enable_bindings_python:
+ config.unsupported = True
diff --git a/mlir/test/mlir-opt-repl/run-tests.test b/mlir/test/mlir-opt-repl/run-tests.test
new file mode 100644
index 0000000000000..4500591f9d7f9
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/run-tests.test
@@ -0,0 +1 @@
+// RUN: %python -m pytest %S -n auto -q --cov=mlir_opt_repl --cov-config=%pyproject --cov-report=term-missing --cov-fail-under=100
diff --git a/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py b/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
new file mode 100644
index 0000000000000..f92fc171fe9a6
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
@@ -0,0 +1,150 @@
+"""Tests for the click CLI entry point and edge cases in engine/diff."""
+
+import json
+import subprocess
+from unittest.mock import patch
+
+from click.testing import CliRunner
+
+import mlir_opt_repl.engine as engine
+from conftest import capture_stdio
+from mlir_opt_repl.__main__ import cli
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+
+class TestClickCLI:
+ def test_help(self):
+ result = CliRunner().invoke(cli, ["--help"])
+ assert result.exit_code == 0
+ assert "mcp" in result.output
+ assert "repl" in result.output
+
+ def test_mcp_help(self):
+ result = CliRunner().invoke(cli, ["mcp", "--help"])
+ assert result.exit_code == 0
+ assert "MCP" in result.output
+
+ def test_repl_help(self):
+ result = CliRunner().invoke(cli, ["repl", "--help"])
+ assert result.exit_code == 0
+ assert "load" in result.output
+
+ def test_default_is_repl(self):
+ result = CliRunner().invoke(cli, [], input="quit\n")
+ assert "mlir-opt-repl" in result.output
+
+ def test_mcp_subcommand(self):
+ msg = json.dumps(
+ {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}
+ )
+ input_text = f"Content-Length: {len(msg.encode())}\r\n\r\n{msg}"
+ result = CliRunner().invoke(cli, ["mcp"], input=input_text)
+ assert "mlir-opt-repl" in result.output
+
+
+class TestEngineEdgeCases:
+ def test_mlir_opt_not_found(self):
+ old = engine.MLIR_OPT
+ engine.MLIR_OPT = "/nonexistent/mlir-opt"
+ try:
+ result = engine.handle_tool_call(
+ "run_pipeline", {"mlir": "module {}", "passes": ["canonicalize"]}
+ )
+ assert result["isError"]
+ assert "not found" in result["content"][0]["text"]
+ finally:
+ engine.MLIR_OPT = old
+
+ def test_check_mlir_opt_exits(self):
+ import pytest
+
+ old = engine.MLIR_OPT
+ engine.MLIR_OPT = "/nonexistent/mlir-opt"
+ try:
+ with pytest.raises(SystemExit):
+ engine.check_mlir_opt()
+ finally:
+ engine.MLIR_OPT = old
+
+ def test_check_mlir_opt_passes(self):
+ engine.check_mlir_opt()
+
+ def test_list_passes_error(self):
+ old = engine.MLIR_OPT
+ engine.MLIR_OPT = "/nonexistent/mlir-opt"
+ try:
+ passes = engine.list_passes()
+ assert len(passes) == 1
+ assert "error" in passes[0]
+ finally:
+ engine.MLIR_OPT = old
+
+ def test_timeout(self):
+ with patch(
+ "mlir_opt_repl.engine.subprocess.run",
+ side_effect=subprocess.TimeoutExpired("cmd", 30),
+ ):
+ result = engine.handle_tool_call(
+ "run_pipeline", {"mlir": "module {}", "passes": ["canonicalize"]}
+ )
+ assert result["isError"]
+ assert "timed out" in result["content"][0]["text"]
+
+
+class TestMCPEdgeCases:
+ def test_empty_content_length(self):
+ with capture_stdio("Content-Length: 0\r\n\r\n") as stdout:
+ try:
+ mcp_main()
+ except Exception:
+ pass
+ assert stdout.getvalue() == ""
+
+
+class TestReplLoadEOF:
+ def test_load_stdin_eof(self):
+ with capture_stdio("load -\nfunc.func @f() { return }") as stdout:
+ try:
+ interactive_main()
+ except (EOFError, SystemExit):
+ pass
+ assert "Loaded" in stdout.getvalue()
+
+
+class TestDiffEdgeCases:
+ def test_non_pretty_side_by_side(self):
+ result = render_side_by_side(
+ ["a", "b"], ["a", "c"], "left", "right", width=60, pretty=False
+ )
+ assert "|" in result
+
+ def test_delete_only(self):
+ result = render_side_by_side(
+ ["a", "b", "c"], ["a"], "left", "right", width=80, pretty=True
+ )
+ assert "b" in result and "c" in result
+
+ def test_insert_only(self):
+ result = render_side_by_side(
+ ["a"], ["a", "b", "c"], "left", "right", width=80, pretty=True
+ )
+ assert "b" in result and "c" in result
+
+ def test_non_pretty_unified(self):
+ result = render_unified_diff(["a", "b"], ["a", "c"], "f1", "f2", pretty=False)
+ assert "-b" in result and "+c" in result
+ assert "\033[" not in result
+
+ def test_truncation(self):
+ result = render_side_by_side(
+ ["x" * 200], ["short"], "left", "right", width=60, pretty=False
+ )
+ assert "…" in result
+
+ def test_replace_uneven_more_left(self):
+ result = render_side_by_side(
+ ["a", "b", "c"], ["x"], "left", "right", width=60, pretty=False
+ )
+ assert "b" in result and "c" in result
diff --git a/mlir/test/mlir-opt-repl/test_mcp.py b/mlir/test/mlir-opt-repl/test_mcp.py
new file mode 100644
index 0000000000000..1a70aabe8921e
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_mcp.py
@@ -0,0 +1,281 @@
+"""Tests for the MCP server protocol."""
+
+from conftest import INIT_MSG, SAMPLE_MLIR, parse_responses, run_mcp, tool_call
+
+
+class TestInitialize:
+ def test_response(self):
+ output = run_mcp(INIT_MSG)
+ r = parse_responses(output)[0]
+ assert r["id"] == 0
+ assert r["result"]["serverInfo"]["name"] == "mlir-opt-repl"
+ assert r["result"]["protocolVersion"] == "2024-11-05"
+ assert r["result"]["capabilities"]["tools"]["listChanged"] is False
+
+
+class TestToolsList:
+ def test_all_tools_present(self):
+ output = run_mcp(
+ INIT_MSG, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
+ )
+ tools = parse_responses(output)[1]["result"]["tools"]
+ names = {t["name"] for t in tools}
+ assert names == {
+ "run_pipeline",
+ "chain_pipeline",
+ "get_current_ir",
+ "reset",
+ "list_passes",
+ "rewind",
+ "history",
+ }
+
+
+class TestRunPipeline:
+ def test_basic(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1,
+ "run_pipeline",
+ {
+ "mlir": SAMPLE_MLIR,
+ "passes": ["convert-arith-to-llvm", "convert-func-to-llvm"],
+ },
+ ),
+ )
+ text = parse_responses(output)[1]["result"]["content"][0]["text"]
+ assert "llvm.func" in text
+ assert "llvm.fadd" in text
+
+ def test_invalid_mlir(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1, "run_pipeline", {"mlir": "not valid", "passes": ["canonicalize"]}
+ ),
+ )
+ r = parse_responses(output)[1]["result"]
+ assert r["isError"] is True
+
+ def test_extra_args(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1,
+ "run_pipeline",
+ {
+ "mlir": "func.func @f() { return }",
+ "passes": ["canonicalize"],
+ "extra_args": ["allow-unregistered-dialect"],
+ },
+ ),
+ )
+ r = parse_responses(output)[1]["result"]
+ assert "isError" not in r
+
+
+class TestChainPipeline:
+ def test_chain(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+ ),
+ tool_call(
+ 2,
+ "chain_pipeline",
+ {"passes": ["convert-arith-to-llvm", "convert-func-to-llvm"]},
+ ),
+ )
+ text = parse_responses(output)[2]["result"]["content"][0]["text"]
+ assert "llvm.func" in text
+
+ def test_no_state(self):
+ output = run_mcp(
+ INIT_MSG, tool_call(1, "chain_pipeline", {"passes": ["canonicalize"]})
+ )
+ r = parse_responses(output)[1]["result"]
+ assert r["isError"] is True
+ assert "no current IR state" in r["content"][0]["text"]
+
+ def test_error_preserves_state(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+ ),
+ tool_call(2, "chain_pipeline", {"passes": ["nonexistent-pass-xyz"]}),
+ tool_call(3, "get_current_ir"),
+ )
+ responses = parse_responses(output)
+ assert responses[2]["result"]["isError"] is True
+ assert "arith.addf" in responses[3]["result"]["content"][0]["text"]
+
+
+class TestGetCurrentIR:
+ def test_no_state(self):
+ output = run_mcp(INIT_MSG, tool_call(1, "get_current_ir"))
+ assert (
+ "(no IR state set)"
+ in parse_responses(output)[1]["result"]["content"][0]["text"]
+ )
+
+ def test_with_state(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1,
+ "run_pipeline",
+ {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+ ),
+ tool_call(2, "get_current_ir"),
+ )
+ assert (
+ "func.func @f" in parse_responses(output)[2]["result"]["content"][0]["text"]
+ )
+
+
+class TestReset:
+ def test_clears_state(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1,
+ "run_pipeline",
+ {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+ ),
+ tool_call(2, "reset"),
+ tool_call(3, "get_current_ir"),
+ )
+ responses = parse_responses(output)
+ assert "IR state cleared" in responses[2]["result"]["content"][0]["text"]
+ assert "(no IR state set)" in responses[3]["result"]["content"][0]["text"]
+
+
+class TestRewind:
+ def test_rewind_one(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+ ),
+ tool_call(2, "chain_pipeline", {"passes": ["convert-arith-to-llvm"]}),
+ tool_call(3, "rewind", {"steps": 1}),
+ )
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "Rewound 1 step(s)" in text
+ assert "arith.addf" in text
+
+ def test_no_history(self):
+ output = run_mcp(INIT_MSG, tool_call(1, "rewind", {"steps": 1}))
+ r = parse_responses(output)[1]["result"]
+ assert r["isError"] is True
+
+ def test_past_beginning(self):
+ output = run_mcp(
+ INIT_MSG,
+ tool_call(
+ 1,
+ "run_pipeline",
+ {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+ ),
+ tool_call(2, "rewind", {"steps": 99}),
+ )
+ assert (
+ "Rewound to beginning"
+ in parse_responses(output)[2]["result"]["content"][0]["text"]
+ )
+
+
+class TestHistory:
+ def _setup(self):
+ return [
+ INIT_MSG,
+ tool_call(
+ 1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+ ),
+ tool_call(2, "chain_pipeline", {"passes": ["convert-arith-to-llvm"]}),
+ ]
+
+ def test_no_history(self):
+ output = run_mcp(INIT_MSG, tool_call(1, "history"))
+ assert (
+ "(no history)" in parse_responses(output)[1]["result"]["content"][0]["text"]
+ )
+
+ def test_unified(self):
+ output = run_mcp(*self._setup(), tool_call(3, "history", {"format": "unified"}))
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "---" in text and "+++" in text
+
+ def test_side_by_side(self):
+ output = run_mcp(
+ *self._setup(),
+ tool_call(3, "history", {"format": "side_by_side", "width": 80}),
+ )
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "--canonicalize" in text and "--convert-arith-to-llvm" in text
+
+ def test_show_ir(self):
+ output = run_mcp(*self._setup(), tool_call(3, "history", {"show_ir": True}))
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "[0] initial" in text and "arith.addf" in text
+
+ def test_pretty_unified(self):
+ output = run_mcp(
+ *self._setup(),
+ tool_call(3, "history", {"format": "unified", "pretty": True}),
+ )
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "\033[" in text
+
+ def test_pretty_side_by_side(self):
+ output = run_mcp(
+ *self._setup(),
+ tool_call(
+ 3, "history", {"format": "side_by_side", "pretty": True, "width": 80}
+ ),
+ )
+ text = parse_responses(output)[3]["result"]["content"][0]["text"]
+ assert "\033[" in text
+
+
+class TestListPasses:
+ def test_filter(self):
+ output = run_mcp(
+ INIT_MSG, tool_call(1, "list_passes", {"filter": "arith-to-llvm"})
+ )
+ assert (
+ "convert-arith-to-llvm"
+ in parse_responses(output)[1]["result"]["content"][0]["text"]
+ )
+
+ def test_no_match(self):
+ output = run_mcp(
+ INIT_MSG, tool_call(1, "list_passes", {"filter": "zzz-nonexistent"})
+ )
+ assert (
+ "(no passes matched)"
+ in parse_responses(output)[1]["result"]["content"][0]["text"]
+ )
+
+
+class TestErrors:
+ def test_unknown_tool(self):
+ output = run_mcp(INIT_MSG, tool_call(1, "unknown_tool"))
+ r = parse_responses(output)[1]["result"]
+ assert r["isError"] is True
+ assert "Unknown tool" in r["content"][0]["text"]
+
+ def test_unknown_method(self):
+ output = run_mcp(
+ INIT_MSG, {"jsonrpc": "2.0", "id": 1, "method": "bad/method", "params": {}}
+ )
+ assert "Method not found" in parse_responses(output)[1]["error"]["message"]
+
+ def test_notification_no_response(self):
+ output = run_mcp(
+ INIT_MSG, {"jsonrpc": "2.0", "method": "notifications/initialized"}
+ )
+ assert len(parse_responses(output)) == 1
diff --git a/mlir/test/mlir-opt-repl/test_repl.py b/mlir/test/mlir-opt-repl/test_repl.py
new file mode 100644
index 0000000000000..be4f214ae0cff
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_repl.py
@@ -0,0 +1,203 @@
+"""Tests for the interactive REPL."""
+
+from conftest import SAMPLE_MLIR, run_repl
+
+LOAD_SAMPLE = f"load -\n{SAMPLE_MLIR}\n\n"
+
+
+class TestHelp:
+ def test_shows_commands(self):
+ output = run_repl("help\nquit\n")
+ for cmd in ("load", "run", "diff", "sbs", "rewind", "quit"):
+ assert cmd in output
+
+
+class TestLoad:
+ def test_stdin(self):
+ output = run_repl("load -\nfunc.func @f() { return }\n\nir\nquit\n")
+ assert "Loaded" in output
+ assert "func.func @f" in output
+
+ def test_multiline(self):
+ output = run_repl(
+ "load -\nmodule {\n func.func @f() {\n return\n }\n}\n\nir\nquit\n"
+ )
+ assert "Loaded" in output
+ assert "module {" in output
+
+ def test_file(self, tmp_path):
+ f = tmp_path / "test.mlir"
+ f.write_text("module {}")
+ output = run_repl(f"load {f}\nir\nquit\n")
+ assert "Loaded" in output
+ assert "module {}" in output
+
+ def test_file_not_found(self):
+ output = run_repl("load /nonexistent/path.mlir\nquit\n")
+ assert "File not found" in output
+
+ def test_no_arg(self):
+ output = run_repl("load\nquit\n")
+ assert "Usage:" in output
+
+ def test_invalid_mlir(self):
+ output = run_repl("load -\nhello\n\nquit\n")
+ assert "Invalid MLIR" in output
+
+ def test_stdin_eof(self):
+ output = run_repl("load -\nfunc.func @f() { return }")
+ assert "Loaded" in output
+
+
+class TestRun:
+ def test_basic(self):
+ output = run_repl(LOAD_SAMPLE + "run canonicalize\nquit\n")
+ assert "arith.addf" in output
+
+ def test_multiple_passes(self):
+ output = run_repl(
+ LOAD_SAMPLE + "run convert-arith-to-llvm convert-func-to-llvm\nquit\n"
+ )
+ assert "llvm.fadd" in output
+
+ def test_no_ir(self):
+ output = run_repl("run canonicalize\nquit\n")
+ assert "No IR loaded" in output
+
+ def test_no_arg(self):
+ output = run_repl("run\nquit\n")
+ assert "Usage:" in output
+
+ def test_invalid_pass(self):
+ output = run_repl(LOAD_SAMPLE + "run nonexistent-pass-xyz\nquit\n")
+ assert "Current IR unchanged" in output
+
+
+class TestHistory:
+ def test_shows_steps(self):
+ output = run_repl(
+ LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nhistory\nquit\n"
+ )
+ assert "initial" in output
+ assert "--canonicalize" in output
+ assert "--convert-arith-to-llvm" in output
+ assert "<-- current" in output
+
+ def test_no_history(self):
+ output = run_repl("history\nquit\n")
+ assert "(no history)" in output
+
+
+class TestDiff:
+ def test_last_step(self):
+ output = run_repl(
+ LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\ndiff\nquit\n"
+ )
+ assert "---" in output
+ assert "+++" in output
+
+ def test_with_indices(self):
+ output = run_repl(
+ LOAD_SAMPLE
+ + "run canonicalize\nrun convert-arith-to-llvm\nrun convert-func-to-llvm\ndiff 0 2\nquit\n"
+ )
+ assert "--- initial" in output
+
+ def test_no_history(self):
+ output = run_repl("diff\nquit\n")
+ assert "need at least 2 history entries" in output
+
+ def test_invalid_indices(self):
+ output = run_repl(
+ LOAD_SAMPLE
+ + "run canonicalize\nrun convert-arith-to-llvm\ndiff 0 99\nquit\n"
+ )
+ assert "Invalid indices" in output
+
+
+class TestSbs:
+ def test_last_step(self):
+ output = run_repl(
+ LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nsbs\nquit\n"
+ )
+ assert "--canonicalize" in output
+ assert "--convert-arith-to-llvm" in output
+
+ def test_with_indices(self):
+ output = run_repl(
+ LOAD_SAMPLE
+ + "run canonicalize\nrun convert-arith-to-llvm\nrun convert-func-to-llvm\nsbs 0 2\nquit\n"
+ )
+ assert "initial" in output
+
+ def test_no_history(self):
+ output = run_repl("sbs\nquit\n")
+ assert "need at least 2 history entries" in output
+
+ def test_invalid_indices(self):
+ output = run_repl(
+ LOAD_SAMPLE
+ + "run canonicalize\nrun convert-arith-to-llvm\nsbs 0 99\nquit\n"
+ )
+ assert "Invalid indices" in output
+
+
+class TestRewind:
+ def test_rewind_one(self):
+ output = run_repl(
+ LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nrewind\nquit\n"
+ )
+ assert "Rewound 1 step(s)" in output
+
+ def test_rewind_past_beginning(self):
+ output = run_repl(LOAD_SAMPLE + "run canonicalize\nrewind 99\nquit\n")
+ assert "Rewound to beginning" in output
+
+ def test_no_history(self):
+ output = run_repl("rewind\nquit\n")
+ assert "No history to rewind" in output
+
+
+class TestReset:
+ def test_clears(self):
+ output = run_repl(LOAD_SAMPLE + "run canonicalize\nreset\nir\nquit\n")
+ assert "IR state cleared" in output
+ assert "(no IR state set)" in output
+
+
+class TestIR:
+ def test_no_state(self):
+ output = run_repl("ir\nquit\n")
+ assert "(no IR state set)" in output
+
+ def test_with_state(self):
+ output = run_repl(LOAD_SAMPLE + "ir\nquit\n")
+ assert "arith.addf" in output
+
+
+class TestPasses:
+ def test_filter(self):
+ output = run_repl("passes arith-to-llvm\nquit\n")
+ assert "convert-arith-to-llvm" in output
+
+ def test_no_match(self):
+ output = run_repl("passes zzz-nonexistent\nquit\n")
+ assert "(no passes matched)" in output
+
+
+class TestMisc:
+ def test_unknown_command(self):
+ output = run_repl("badcommand\nquit\n")
+ assert "Unknown command" in output
+
+ def test_eof_exits(self):
+ output = run_repl("")
+ assert "mlir-opt-repl" in output
+
+ def test_empty_lines_ignored(self):
+ output = run_repl("\n\n\nquit\n")
+ assert "mlir-opt-repl" in output
+
+ def test_exit_alias(self):
+ output = run_repl("exit\n")
+ assert "mlir-opt-repl" in output
diff --git a/mlir/tools/mlir-opt-repl/README.md b/mlir/tools/mlir-opt-repl/README.md
new file mode 100644
index 0000000000000..9eec15b3c3e06
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/README.md
@@ -0,0 +1,134 @@
+# mlir-opt-repl
+
+An interactive MLIR pass pipeline explorer. Feed MLIR through passes
+incrementally, inspect intermediate states, rewind to try different lowering
+paths, and diff what each pass changed.
+
+Also functions as an MCP (Model Context Protocol) server for Claude Code.
+
+## Installation
+
+```bash
+pip install mlir-opt-repl
+```
+
+`mlir-opt` must be on your PATH, or set via `MLIR_OPT`:
+
+```bash
+export MLIR_OPT=/path/to/llvm-project/build/bin/mlir-opt
+```
+
+## Interactive REPL (default)
+
+```bash
+mlir-opt-repl
+```
+
+Commands: `load`, `run`, `ir`, `history`, `diff`, `sbs`, `rewind`, `reset`, `passes`, `quit`.
+Diffs are rendered with ANSI colors (red/green for changes, dim for context).
+
+```
+mlir-opt-repl> load test.mlir
+mlir-opt-repl> run canonicalize
+mlir-opt-repl> run convert-arith-to-llvm
+mlir-opt-repl> diff
+mlir-opt-repl> sbs 0 2
+mlir-opt-repl> rewind 1
+```
+
+## MCP Server (for Claude Code)
+
+```bash
+mlir-opt-repl mcp
+```
+
+To configure Claude Code, add to `.claude/settings.local.json`:
+
+```json
+{
+ "mcpServers": {
+ "mlir-opt-repl": {
+ "command": "mlir-opt-repl",
+ "args": ["mcp"],
+ "env": {
+ "MLIR_OPT": "/path/to/build/bin/mlir-opt"
+ }
+ }
+ }
+}
+```
+
+For development without installing:
+
+```json
+{
+ "mcpServers": {
+ "mlir-opt-repl": {
+ "command": "python3",
+ "args": ["-m", "mlir_opt_repl", "mcp"],
+ "env": {
+ "PYTHONPATH": "/path/to/llvm-project/mlir/tools/mlir-opt-repl/src",
+ "MLIR_OPT": "/path/to/build/bin/mlir-opt"
+ }
+ }
+ }
+}
+```
+
+### MCP Tools
+
+| Tool | Description |
+|------|-------------|
+| `run_pipeline` | Run MLIR source through passes, stores result as current state |
+| `chain_pipeline` | Apply additional passes to the current IR state |
+| `get_current_ir` | Show the current IR without running passes |
+| `reset` | Clear current IR state and history |
+| `rewind` | Undo the last N pass applications |
+| `history` | Show pass application timeline, with unified or side-by-side diffs |
+| `list_passes` | List available mlir-opt passes with optional filter |
+
+### Example MCP Session
+
+```
+run_pipeline(mlir="func.func @f(...) ...", passes=["canonicalize"])
+ → canonicalized IR (saved as state)
+
+chain_pipeline(passes=["convert-arith-to-llvm"])
+ → arith ops converted to LLVM dialect
+
+history(format="unified")
+ → shows unified diff between each step
+
+rewind(steps=1)
+ → back to post-canonicalize state, try a different path
+
+history(format="side_by_side", width=120)
+ → two-column comparison of each step
+```
+
+## Project Structure
+
+```
+pyproject.toml
+src/mlir_opt_repl/
+ __init__.py
+ __main__.py — Click CLI: mlir-opt-repl [mcp|repl]
+ engine.py — Core state + logic: run_mlir_opt, list_passes, handle_tool_call
+ mcp.py — MCP protocol: TOOLS schema, send/recv, dispatch
+ repl.py — Interactive terminal REPL
+ diff.py — Side-by-side and unified diff renderers
+ render.py — ANSI color constants
+```
+
+## Testing
+
+Tests live in `mlir/test/mlir-opt-repl/` and use pytest with 100% line coverage:
+
+```bash
+PYTHONPATH=mlir/tools/mlir-opt-repl/src \
+MLIR_OPT=/path/to/build/bin/mlir-opt \
+ python3 -m pytest mlir/test/mlir-opt-repl \
+ --cov=mlir_opt_repl \
+ --cov-config=mlir/tools/mlir-opt-repl/pyproject.toml \
+ --cov-fail-under=100
+```
diff --git a/mlir/tools/mlir-opt-repl/pyproject.toml b/mlir/tools/mlir-opt-repl/pyproject.toml
new file mode 100644
index 0000000000000..897c17cbb5276
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/pyproject.toml
@@ -0,0 +1,37 @@
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "mlir-opt-repl"
+version = "0.1.0"
+description = "Interactive MLIR pass pipeline explorer — MCP server and terminal REPL"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.9"
+dependencies = ["click>=8.0"]
+authors = [
+ {name = "Maksim Levental", email = "maksim.levental at gmail.com"},
+]
+keywords = ["mlir", "llvm", "mcp", "repl", "compiler"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Topic :: Software Development :: Compilers",
+ "Programming Language :: Python :: 3",
+]
+
+[project.scripts]
+mlir-opt-repl = "mlir_opt_repl.__main__:main"
+
+[project.urls]
+Repository = "https://github.com/llvm/llvm-project/tree/main/mlir/tools/mlir-opt-repl"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.coverage.report]
+exclude_lines = [
+ "if __name__ == .__main__.",
+ "^def main\\(\\):",
+]
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
new file mode 100644
index 0000000000000..811b20444e0a5
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
@@ -0,0 +1,3 @@
+from mlir_opt_repl.render import * # noqa: F401,F403
+from mlir_opt_repl.diff import * # noqa: F401,F403
+from mlir_opt_repl.engine import * # noqa: F401,F403
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
new file mode 100644
index 0000000000000..b142029b586bb
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
@@ -0,0 +1,94 @@
+"""Entry point for mlir-opt-repl.
+
+Provides two modes:
+ - MCP server: speaks Model Context Protocol over stdio,
+ for use as a Claude Code tool server.
+ - Interactive REPL (default): a terminal interface for exploring MLIR pass
+ pipelines with colored diffs and history navigation.
+"""
+
+import click
+
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+
+ at click.group(invoke_without_command=True)
+ at click.pass_context
+def cli(ctx):
+ """mlir-opt-repl: Interactive MLIR pass pipeline explorer.
+
+ An MCP server and terminal REPL for running mlir-opt passes
+ incrementally, inspecting intermediate IR, rewinding to try
+ different lowering paths, and diffing what each pass changed.
+
+ By default (no subcommand), starts the interactive REPL.
+ Use 'mlir-opt-repl mcp' to start the MCP server for Claude Code.
+ """
+ if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
+ ctx.invoke(repl)
+
+
+ at cli.command()
+def mcp():
+ """Start the MCP (Model Context Protocol) server over stdio.
+
+ This is the mode used by Claude Code. The server accepts JSON-RPC
+ messages on stdin and responds on stdout, exposing tools for running
+ MLIR through pass pipelines, chaining passes, rewinding, and diffing.
+
+ To use with Claude Code, add to .claude/settings.local.json:
+
+ \b
+ {
+ "mcpServers": {
+ "mlir-opt-repl": {
+ "command": "mlir-opt-repl",
+ "args": ["mcp"],
+ "env": {"MLIR_OPT": "/path/to/mlir-opt"}
+ }
+ }
+ }
+ """
+ mcp_main()
+
+
+ at cli.command()
+def repl():
+ """Start an interactive terminal REPL.
+
+ Provides a command-line interface for loading MLIR, applying passes
+ step by step, viewing colored diffs (unified or side-by-side),
+ rewinding to try different lowering paths, and browsing history.
+
+ \b
+ Commands:
+ load <file.mlir> Load MLIR from a file
+ load - Load MLIR from stdin (blank line to finish)
+ run <passes...> Apply passes to current IR
+ ir Show current IR
+ history Show pass application history
+ diff [a b] Unified diff between steps
+ sbs [a b] Side-by-side diff between steps
+ rewind [N] Undo last N steps (default 1)
+ reset Clear all state
+ passes [filter] List available passes
+ quit Exit
+
+ \b
+ Example:
+ $ MLIR_OPT=/path/to/mlir-opt mlir-opt-repl
+ mlir-opt-repl> load test.mlir
+ mlir-opt-repl> run canonicalize
+ mlir-opt-repl> run convert-arith-to-llvm
+ mlir-opt-repl> diff
+ """
+ interactive_main()
+
+
+def main():
+ cli()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
new file mode 100644
index 0000000000000..5af5ddf3dfbc4
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
@@ -0,0 +1,91 @@
+import difflib
+import shutil
+
+from mlir_opt_repl.render import BOLD, CYAN, DIM, GREEN, RED, RESET
+
+
+def render_side_by_side(
+ left_lines, right_lines, left_title, right_title, width=None, pretty=True
+):
+ if width is None:
+ width = shutil.get_terminal_size((120, 24)).columns
+ col_width = (width - 3) // 2
+
+ def truncate(s, w):
+ if len(s) <= w:
+ return s + " " * (w - len(s))
+ return s[: w - 1] + "…"
+
+ if pretty:
+ r, g, c, d, b, rst = RED, GREEN, CYAN, DIM, BOLD, RESET
+ else:
+ r, g, c, d, b, rst = "", "", "", "", "", ""
+
+ lines = []
+ header = (
+ f"{b}{c}{truncate(left_title, col_width)}{rst}"
+ f" {d}│{rst} "
+ f"{b}{c}{truncate(right_title, col_width)}{rst}"
+ )
+ lines.append(header)
+ sep_char = "─" if pretty else "-"
+ mid_char = "┼" if pretty else "+"
+ div_char = "│" if pretty else "|"
+ lines.append(d + sep_char * col_width + mid_char + sep_char * (col_width + 2) + rst)
+
+ opcodes = difflib.SequenceMatcher(None, left_lines, right_lines).get_opcodes()
+ for tag, i1, i2, j1, j2 in opcodes:
+ if tag == "equal":
+ for i, j in zip(range(i1, i2), range(j1, j2)):
+ l = truncate(left_lines[i], col_width)
+ rv = truncate(right_lines[j], col_width)
+ lines.append(f"{d}{l}{rst} {d}{div_char}{rst} {d}{rv}{rst}")
+ elif tag == "replace":
+ max_len = max(i2 - i1, j2 - j1)
+ for k in range(max_len):
+ if k < i2 - i1:
+ l = f"{r}{truncate(left_lines[i1 + k], col_width)}{rst}"
+ else:
+ l = " " * col_width
+ if k < j2 - j1:
+ rv = f"{g}{truncate(right_lines[j1 + k], col_width)}{rst}"
+ else:
+ rv = " " * col_width
+ lines.append(f"{l} {d}{div_char}{rst} {rv}")
+ elif tag == "delete":
+ for i in range(i1, i2):
+ l = f"{r}{truncate(left_lines[i], col_width)}{rst}"
+ lines.append(f"{l} {d}{div_char}{rst}")
+ elif tag == "insert":
+ for j in range(j1, j2):
+ rv = f"{g}{truncate(right_lines[j], col_width)}{rst}"
+ lines.append(f"{' ' * col_width} {d}{div_char}{rst} {rv}")
+ return "\n".join(lines)
+
+
+def render_unified_diff(prev_lines, curr_lines, prev_title, curr_title, pretty=True):
+ diff = list(
+ difflib.unified_diff(
+ prev_lines,
+ curr_lines,
+ fromfile=prev_title,
+ tofile=curr_title,
+ lineterm="",
+ )
+ )
+ if not pretty:
+ return "\n".join(diff)
+
+ colored = []
+ for line in diff:
+ if line.startswith("---") or line.startswith("+++"):
+ colored.append(f"{BOLD}{line}{RESET}")
+ elif line.startswith("@@"):
+ colored.append(f"{CYAN}{line}{RESET}")
+ elif line.startswith("-"):
+ colored.append(f"{RED}{line}{RESET}")
+ elif line.startswith("+"):
+ colored.append(f"{GREEN}{line}{RESET}")
+ else:
+ colored.append(f"{DIM}{line}{RESET}")
+ return "\n".join(colored)
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
new file mode 100644
index 0000000000000..72f2b1a06f353
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
@@ -0,0 +1,211 @@
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+
+MLIR_OPT = os.environ.get("MLIR_OPT", "mlir-opt")
+
+
+def check_mlir_opt():
+ if not shutil.which(MLIR_OPT):
+ print(
+ f"error: mlir-opt not found at '{MLIR_OPT}'. "
+ "Set MLIR_OPT to the path of your mlir-opt binary.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+current_ir = None
+ir_history = []
+
+
+def run_mlir_opt(ir_text, args):
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".mlir", delete=False) as f:
+ f.write(ir_text)
+ f.flush()
+ tmp_path = f.name
+
+ try:
+ cmd = [MLIR_OPT] + args + [tmp_path]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ if result.returncode != 0:
+ return None, result.stderr.strip()
+ return result.stdout.strip(), None
+ except subprocess.TimeoutExpired:
+ return None, "mlir-opt timed out after 30 seconds"
+ except FileNotFoundError:
+ return None, f"mlir-opt not found at: {MLIR_OPT}"
+ finally:
+ os.unlink(tmp_path)
+
+
+def list_passes():
+ try:
+ result = subprocess.run(
+ [MLIR_OPT, "--help"], capture_output=True, text=True, timeout=10
+ )
+ passes = []
+ for line in result.stdout.splitlines():
+ stripped = line.strip()
+ if (
+ stripped.startswith("--convert-")
+ or stripped.startswith("--canonicalize")
+ or stripped.startswith("--cse")
+ or stripped.startswith("--inline")
+ or stripped.startswith("--mem2reg")
+ or stripped.startswith("--symbol-")
+ ):
+ parts = stripped.split(" - ", 1)
+ name = parts[0].strip().lstrip("-")
+ desc = parts[1].strip() if len(parts) > 1 else ""
+ passes.append({"name": name, "description": desc})
+ return passes
+ except Exception as e:
+ return [{"error": str(e)}]
+
+
+def handle_tool_call(name, arguments):
+ global current_ir, ir_history
+
+ if name == "run_pipeline":
+ mlir = arguments["mlir"]
+ passes = ["--" + p.lstrip("-") for p in arguments["passes"]]
+ extra = ["--" + a.lstrip("-") for a in arguments.get("extra_args", [])]
+ output, err = run_mlir_opt(mlir, passes + extra)
+ if err:
+ return {
+ "content": [{"type": "text", "text": f"Error:\n{err}"}],
+ "isError": True,
+ }
+ ir_history = [("initial", mlir)]
+ current_ir = output
+ ir_history.append((" ".join(passes), output))
+ return {"content": [{"type": "text", "text": output}]}
+
+ elif name == "chain_pipeline":
+ if current_ir is None:
+ return {
+ "content": [
+ {
+ "type": "text",
+ "text": "Error: no current IR state. Use run_pipeline first.",
+ }
+ ],
+ "isError": True,
+ }
+ passes = ["--" + p.lstrip("-") for p in arguments["passes"]]
+ extra = ["--" + a.lstrip("-") for a in arguments.get("extra_args", [])]
+ output, err = run_mlir_opt(current_ir, passes + extra)
+ if err:
+ return {
+ "content": [
+ {"type": "text", "text": f"Error:\n{err}\n\nCurrent IR unchanged."}
+ ],
+ "isError": True,
+ }
+ current_ir = output
+ ir_history.append((" ".join(passes), output))
+ return {"content": [{"type": "text", "text": output}]}
+
+ elif name == "get_current_ir":
+ if current_ir is None:
+ return {"content": [{"type": "text", "text": "(no IR state set)"}]}
+ return {"content": [{"type": "text", "text": current_ir}]}
+
+ elif name == "reset":
+ current_ir = None
+ ir_history = []
+ return {"content": [{"type": "text", "text": "IR state cleared."}]}
+
+ elif name == "rewind":
+ if not ir_history:
+ return {
+ "content": [{"type": "text", "text": "Error: no history to rewind."}],
+ "isError": True,
+ }
+ steps = arguments.get("steps", 1)
+ if steps >= len(ir_history):
+ desc, ir = ir_history[0]
+ ir_history = [ir_history[0]]
+ current_ir = ir
+ return {
+ "content": [
+ {
+ "type": "text",
+ "text": f"Rewound to beginning ({desc}).\n\n{ir}",
+ }
+ ]
+ }
+ ir_history = ir_history[:-steps]
+ desc, ir = ir_history[-1]
+ current_ir = ir
+ return {
+ "content": [
+ {
+ "type": "text",
+ "text": f"Rewound {steps} step(s). Now at: {desc}\n\n{ir}",
+ }
+ ]
+ }
+
+ elif name == "history":
+ if not ir_history:
+ return {"content": [{"type": "text", "text": "(no history)"}]}
+ show_ir = arguments.get("show_ir", False)
+ fmt = arguments.get("format")
+ pretty = arguments.get("pretty", False)
+ width = arguments.get("width")
+ lines = []
+ for i, (desc, ir) in enumerate(ir_history):
+ marker = " <-- current" if i == len(ir_history) - 1 else ""
+ lines.append(f"[{i}] {desc}{marker}")
+ if fmt == "side_by_side" and i > 0:
+ prev_ir = ir_history[i - 1][1]
+ sbs = render_side_by_side(
+ prev_ir.splitlines(),
+ ir.splitlines(),
+ ir_history[i - 1][0],
+ desc,
+ width=width,
+ pretty=pretty,
+ )
+ lines.append(sbs)
+ lines.append("")
+ elif fmt == "unified" and i > 0:
+ prev_ir = ir_history[i - 1][1]
+ lines.append(
+ render_unified_diff(
+ prev_ir.splitlines(),
+ ir.splitlines(),
+ ir_history[i - 1][0],
+ desc,
+ pretty=pretty,
+ )
+ )
+ lines.append("")
+ elif show_ir:
+ lines.append(ir)
+ lines.append("")
+ return {"content": [{"type": "text", "text": "\n".join(lines)}]}
+
+ elif name == "list_passes":
+ filt = arguments.get("filter", "")
+ passes = list_passes()
+ if filt:
+ passes = [
+ p
+ for p in passes
+ if filt.lower() in p.get("name", "").lower()
+ or filt.lower() in p.get("description", "").lower()
+ ]
+ text = "\n".join(f"--{p['name']}: {p['description']}" for p in passes)
+ return {"content": [{"type": "text", "text": text or "(no passes matched)"}]}
+
+ return {
+ "content": [{"type": "text", "text": f"Unknown tool: {name}"}],
+ "isError": True,
+ }
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
new file mode 100644
index 0000000000000..a25e2d0247b54
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
@@ -0,0 +1,201 @@
+import json
+import sys
+
+from mlir_opt_repl.engine import check_mlir_opt, handle_tool_call
+
+TOOLS = [
+ {
+ "name": "run_pipeline",
+ "description": "Run MLIR source through mlir-opt with the given passes. Sets the result as the current IR state for subsequent chain_pipeline calls.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "mlir": {
+ "type": "string",
+ "description": "MLIR source text to process",
+ },
+ "passes": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "List of pass flags (e.g. ['--convert-arith-to-llvm', '--convert-func-to-llvm']). Can also be a single pass-pipeline string.",
+ },
+ "extra_args": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Additional mlir-opt arguments (e.g. ['--allow-unregistered-dialect'])",
+ "default": [],
+ },
+ },
+ "required": ["mlir", "passes"],
+ },
+ },
+ {
+ "name": "chain_pipeline",
+ "description": "Feed the current IR state (from a previous run_pipeline or chain_pipeline) through additional passes. Use this to incrementally lower IR step by step.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "passes": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "List of pass flags to apply to the current IR state",
+ },
+ "extra_args": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Additional mlir-opt arguments",
+ "default": [],
+ },
+ },
+ "required": ["passes"],
+ },
+ },
+ {
+ "name": "get_current_ir",
+ "description": "Return the current IR state without running any passes.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {},
+ },
+ },
+ {
+ "name": "reset",
+ "description": "Clear the current IR state.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {},
+ },
+ },
+ {
+ "name": "list_passes",
+ "description": "List available mlir-opt passes (conversion passes, canonicalize, cse, etc.)",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "filter": {
+ "type": "string",
+ "description": "Optional substring to filter pass names",
+ "default": "",
+ }
+ },
+ },
+ },
+ {
+ "name": "rewind",
+ "description": "Rewind the IR state by N steps (default 1). Undoes the last N pass applications.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "steps": {
+ "type": "integer",
+ "description": "Number of steps to rewind (default 1)",
+ "default": 1,
+ "minimum": 1,
+ }
+ },
+ },
+ },
+ {
+ "name": "history",
+ "description": "Show the history of pass applications and their IR states.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "show_ir": {
+ "type": "boolean",
+ "description": "Include full IR text in each history entry",
+ "default": False,
+ },
+ "format": {
+ "type": "string",
+ "enum": ["unified", "side_by_side"],
+ "description": "Diff format: 'unified' for standard unified diff, 'side_by_side' for two-column comparison",
+ },
+ "pretty": {
+ "type": "boolean",
+ "description": "Use ANSI colors and box-drawing characters for terminal display",
+ "default": False,
+ },
+ "width": {
+ "type": "integer",
+ "description": "Terminal width for side-by-side mode (auto-detected if omitted)",
+ },
+ },
+ },
+ },
+]
+
+
+def send(msg):
+ text = json.dumps(msg)
+ sys.stdout.write(f"Content-Length: {len(text.encode())}\r\n\r\n{text}")
+ sys.stdout.flush()
+
+
+def read_message():
+ headers = {}
+ while True:
+ line = sys.stdin.readline()
+ if not line:
+ return None
+ line = line.strip()
+ if line == "":
+ break
+ if ":" in line:
+ key, val = line.split(":", 1)
+ headers[key.strip()] = val.strip()
+
+ length = int(headers.get("Content-Length", 0))
+ if length == 0:
+ return None
+ body = sys.stdin.read(length)
+ return json.loads(body)
+
+
+def dispatch(msg):
+ method = msg.get("method", "")
+ msg_id = msg.get("id")
+
+ if method == "initialize":
+ send(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {"listChanged": False}},
+ "serverInfo": {"name": "mlir-opt-repl", "version": "0.1.0"},
+ },
+ }
+ )
+ elif method == "notifications/initialized":
+ pass
+ elif method == "tools/list":
+ send(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {"tools": TOOLS},
+ }
+ )
+ elif method == "tools/call":
+ params = msg.get("params", {})
+ result = handle_tool_call(params["name"], params.get("arguments", {}))
+ send({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ elif msg_id is not None:
+ send(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
+ }
+ )
+
+
+def mcp_main():
+ check_mlir_opt()
+ while True:
+ msg = read_message()
+ if msg is None:
+ break
+ dispatch(msg)
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
new file mode 100644
index 0000000000000..2c40e8be2ad03
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
@@ -0,0 +1,7 @@
+# ANSI escape codes
+RED = "\033[31m"
+GREEN = "\033[32m"
+CYAN = "\033[36m"
+DIM = "\033[2m"
+BOLD = "\033[1m"
+RESET = "\033[0m"
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py
new file mode 100644
index 0000000000000..aaf07304e0f90
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py
@@ -0,0 +1,204 @@
+from mlir_opt_repl import engine
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+from mlir_opt_repl.render import BOLD, CYAN, DIM, GREEN, RED, RESET
+
+
+def interactive_main():
+ import readline # noqa: F401 — enables line editing in input()
+
+ engine.check_mlir_opt()
+
+ print(f"{BOLD}mlir-opt-repl{RESET} (using {engine.MLIR_OPT})")
+ print(
+ "Commands: load <file>, run <passes...>, rewind [N], history, diff, sbs, ir, reset, quit"
+ )
+ print()
+
+ while True:
+ try:
+ line = input(f"{CYAN}mlir-opt-repl>{RESET} ")
+ except (EOFError, KeyboardInterrupt):
+ print()
+ break
+
+ line = line.strip()
+ if not line:
+ continue
+
+ parts = line.split()
+ cmd = parts[0]
+
+ if cmd in ("quit", "exit", "q"):
+ break
+
+ elif cmd == "load":
+ if len(parts) < 2:
+ print(
+ f"{RED}Usage: load <file.mlir> OR load - (read from stdin until blank line){RESET}"
+ )
+ continue
+ if parts[1] == "-":
+ print(f"{DIM}Enter MLIR (blank line to finish):{RESET}")
+ mlir_lines = []
+ while True:
+ try:
+ l = input()
+ except EOFError:
+ break
+ if l == "":
+ break
+ mlir_lines.append(l)
+ mlir_text = "\n".join(mlir_lines)
+ else:
+ path = " ".join(parts[1:])
+ try:
+ with open(path) as f:
+ mlir_text = f.read()
+ except FileNotFoundError:
+ print(f"{RED}File not found: {path}{RESET}")
+ continue
+ # Validate the IR by running mlir-opt with no passes
+ _, err = engine.run_mlir_opt(mlir_text, [])
+ if err:
+ print(f"{RED}Invalid MLIR:{RESET}")
+ print(f"{RED}{err}{RESET}")
+ continue
+ engine.current_ir = mlir_text
+ engine.ir_history = [("initial", mlir_text)]
+ print(
+ f"{GREEN}Loaded ({len(mlir_text)} bytes). Use 'run <passes>' to apply passes.{RESET}"
+ )
+
+ elif cmd == "run":
+ if not parts[1:]:
+ print(f"{RED}Usage: run <pass1> [pass2] ...{RESET}")
+ continue
+ if engine.current_ir is None:
+ print(f"{RED}No IR loaded. Use 'load <file>' first.{RESET}")
+ continue
+ passes = ["--" + p.lstrip("-") for p in parts[1:]]
+ output, err = engine.run_mlir_opt(engine.current_ir, passes)
+ if err:
+ print(f"{RED}{err}{RESET}")
+ print(f"{DIM}Current IR unchanged.{RESET}")
+ else:
+ engine.current_ir = output
+ engine.ir_history.append((" ".join(passes), output))
+ print(output)
+
+ elif cmd == "rewind":
+ if not engine.ir_history:
+ print(f"{RED}No history to rewind.{RESET}")
+ continue
+ steps = int(parts[1]) if len(parts) > 1 else 1
+ if steps >= len(engine.ir_history):
+ desc, ir = engine.ir_history[0]
+ engine.ir_history = [engine.ir_history[0]]
+ engine.current_ir = ir
+ print(f"{GREEN}Rewound to beginning ({desc}).{RESET}")
+ else:
+ engine.ir_history = engine.ir_history[:-steps]
+ desc, ir = engine.ir_history[-1]
+ engine.current_ir = ir
+ print(f"{GREEN}Rewound {steps} step(s). Now at: {desc}{RESET}")
+ print()
+ print(engine.current_ir)
+
+ elif cmd == "history":
+ if not engine.ir_history:
+ print(f"{DIM}(no history){RESET}")
+ continue
+ for i, (desc, _) in enumerate(engine.ir_history):
+ marker = (
+ f" {GREEN}<-- current{RESET}"
+ if i == len(engine.ir_history) - 1
+ else ""
+ )
+ print(f" {BOLD}[{i}]{RESET} {desc}{marker}")
+
+ elif cmd == "diff":
+ if len(engine.ir_history) < 2:
+ print(f"{DIM}(need at least 2 history entries){RESET}")
+ continue
+ if len(parts) == 3:
+ a, b = int(parts[1]), int(parts[2])
+ else:
+ a, b = len(engine.ir_history) - 2, len(engine.ir_history) - 1
+ if a < 0 or b >= len(engine.ir_history):
+ print(f"{RED}Invalid indices (0..{len(engine.ir_history)-1}){RESET}")
+ continue
+ print(
+ render_unified_diff(
+ engine.ir_history[a][1].splitlines(),
+ engine.ir_history[b][1].splitlines(),
+ engine.ir_history[a][0],
+ engine.ir_history[b][0],
+ pretty=True,
+ )
+ )
+
+ elif cmd == "sbs":
+ if len(engine.ir_history) < 2:
+ print(f"{DIM}(need at least 2 history entries){RESET}")
+ continue
+ if len(parts) == 3:
+ a, b = int(parts[1]), int(parts[2])
+ else:
+ a, b = len(engine.ir_history) - 2, len(engine.ir_history) - 1
+ if a < 0 or b >= len(engine.ir_history):
+ print(f"{RED}Invalid indices (0..{len(engine.ir_history)-1}){RESET}")
+ continue
+ print(
+ render_side_by_side(
+ engine.ir_history[a][1].splitlines(),
+ engine.ir_history[b][1].splitlines(),
+ engine.ir_history[a][0],
+ engine.ir_history[b][0],
+ pretty=True,
+ )
+ )
+
+ elif cmd == "ir":
+ if engine.current_ir is None:
+ print(f"{DIM}(no IR state set){RESET}")
+ else:
+ print(engine.current_ir)
+
+ elif cmd == "reset":
+ engine.current_ir = None
+ engine.ir_history = []
+ print(f"{GREEN}IR state cleared.{RESET}")
+
+ elif cmd == "passes":
+ filt = parts[1] if len(parts) > 1 else ""
+ all_passes = engine.list_passes()
+ if filt:
+ all_passes = [
+ p
+ for p in all_passes
+ if filt.lower() in p.get("name", "").lower()
+ or filt.lower() in p.get("description", "").lower()
+ ]
+ for p in all_passes:
+ print(f" {BOLD}--{p['name']}{RESET}: {DIM}{p['description']}{RESET}")
+ if not all_passes:
+ print(f"{DIM}(no passes matched){RESET}")
+
+ elif cmd == "help":
+ print(f"""
+{BOLD}Commands:{RESET}
+ {CYAN}load <file.mlir>{RESET} Load MLIR from a file
+ {CYAN}load -{RESET} Load MLIR from stdin (blank line to finish)
+ {CYAN}run <passes...>{RESET} Apply passes to current IR
+ {CYAN}ir{RESET} Show current IR
+ {CYAN}history{RESET} Show pass application history
+ {CYAN}diff [a b]{RESET} Unified diff (last step, or between indices a and b)
+ {CYAN}sbs [a b]{RESET} Side-by-side diff (last step, or between indices a and b)
+ {CYAN}rewind [N]{RESET} Undo last N steps (default 1)
+ {CYAN}reset{RESET} Clear all state
+ {CYAN}passes [filter]{RESET} List available passes
+ {CYAN}quit{RESET} Exit
+""")
+
+ else:
+ print(f"{RED}Unknown command: {cmd}. Type 'help' for usage.{RESET}")
More information about the llvm-commits
mailing list