From caecb020830d17351b4a74294578b41f5d17f217 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 14 Jun 2026 10:42:18 -0700 Subject: [PATCH 01/11] keyring-style dart installation rather than holding keys --- tool/gh_codespaces/install_dart.sh | 21 ++- tool/gh_codespaces/pubkeys/dart.pub | 267 ---------------------------- 2 files changed, 10 insertions(+), 278 deletions(-) delete mode 100644 tool/gh_codespaces/pubkeys/dart.pub diff --git a/tool/gh_codespaces/install_dart.sh b/tool/gh_codespaces/install_dart.sh index abbe39a0c..3fc47fcd7 100755 --- a/tool/gh_codespaces/install_dart.sh +++ b/tool/gh_codespaces/install_dart.sh @@ -11,21 +11,20 @@ set -euo pipefail -# Add Dart repository key. - -declare -r input_pubkey_file='tool/gh_codespaces/pubkeys/dart.pub' -declare -r output_pubkey_file='/usr/share/keyrings/dart.gpg' - -sudo gpg --output ${output_pubkey_file} --dearmor ${input_pubkey_file} +sudo apt-get update +sudo apt-get install -y wget gpg apt-transport-https -# Add Dart repository. +sudo mkdir -p /usr/share/keyrings +wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub \ + | gpg --dearmor \ + | sudo tee /usr/share/keyrings/dart.gpg >/dev/null -declare -r dart_repository_url='https://storage.googleapis.com/download.dartlang.org/linux/debian' -declare -r dart_repository_file='/etc/apt/sources.list.d/dart.list' +# Add Dart repository key. -echo "deb [signed-by=${output_pubkey_file}] ${dart_repository_url} stable main" | sudo tee ${dart_repository_file} +echo "deb [signed-by=/usr/share/keyrings/dart.gpg] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main" \ + | sudo tee /etc/apt/sources.list.d/dart_stable.list # Install Dart. sudo apt-get update -sudo apt-get install dart +sudo apt-get install -y dart diff --git a/tool/gh_codespaces/pubkeys/dart.pub b/tool/gh_codespaces/pubkeys/dart.pub deleted file mode 100644 index 0366239cb..000000000 --- a/tool/gh_codespaces/pubkeys/dart.pub +++ /dev/null @@ -1,267 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- -Version: GnuPG v1.4.2.2 (GNU/Linux) - -mQGiBEXwb0YRBADQva2NLpYXxgjNkbuP0LnPoEXruGmvi3XMIxjEUFuGNCP4Rj/a -kv2E5VixBP1vcQFDRJ+p1puh8NU0XERlhpyZrVMzzS/RdWdyXf7E5S8oqNXsoD1z -fvmI+i9b2EhHAA19Kgw7ifV8vMa4tkwslEmcTiwiw8lyUl28Wh4Et8SxzwCggDcA -feGqtn3PP5YAdD0km4S4XeMEAJjlrqPoPv2Gf//tfznY2UyS9PUqFCPLHgFLe80u -QhI2U5jt6jUKN4fHauvR6z3seSAsh1YyzyZCKxJFEKXCCqnrFSoh4WSJsbFNc4PN -b0V0SqiTCkWADZyLT5wll8sWuQ5ylTf3z1ENoHf+G3um3/wk/+xmEHvj9HCTBEXP -78X0A/0Tqlhc2RBnEf+AqxWvM8sk8LzJI/XGjwBvKfXe+l3rnSR2kEAvGzj5Sg0X -4XmfTg4Jl8BNjWyvm2Wmjfet41LPmYJKsux3g0b8yzQxeOA4pQKKAU3Z4+rgzGmf -HdwCG5MNT2A5XxD/eDd+L4fRx0HbFkIQoAi1J3YWQSiTk15fw7RMR29vZ2xlLCBJ -bmMuIExpbnV4IFBhY2thZ2UgU2lnbmluZyBLZXkgPGxpbnV4LXBhY2thZ2VzLWtl -eW1hc3RlckBnb29nbGUuY29tPohjBBMRAgAjAhsDBgsJCAcDAgQVAggDBBYCAwEC -HgECF4AFAkYVdn8CGQEACgkQoECDD3+sWZHKSgCfdq3HtNYJLv+XZleb6HN4zOcF -AJEAniSFbuv8V5FSHxeRimHx25671az+uQINBEXwb0sQCACuA8HT2nr+FM5y/kzI -A51ZcC46KFtIDgjQJ31Q3OrkYP8LbxOpKMRIzvOZrsjOlFmDVqitiVc7qj3lYp6U -rgNVaFv6Qu4bo2/ctjNHDDBdv6nufmusJUWq/9TwieepM/cwnXd+HMxu1XBKRVk9 -XyAZ9SvfcW4EtxVgysI+XlptKFa5JCqFM3qJllVohMmr7lMwO8+sxTWTXqxsptJo -pZeKz+UBEEqPyw7CUIVYGC9ENEtIMFvAvPqnhj1GS96REMpry+5s9WKuLEaclWpd -K3krttbDlY1NaeQUCRvBYZ8iAG9YSLHUHMTuI2oea07Rh4dtIAqPwAX8xn36JAYG -2vgLAAMFB/wKqaycjWAZwIe98Yt0qHsdkpmIbarD9fGiA6kfkK/UxjL/k7tmS4Vm -CljrrDZkPSQ/19mpdRcGXtb0NI9+nyM5trweTvtPw+HPkDiJlTaiCcx+izg79Fj9 -KcofuNb3lPdXZb9tzf5oDnmm/B+4vkeTuEZJ//IFty8cmvCpzvY+DAz1Vo9rA+Zn -cpWY1n6z6oSS9AsyT/IFlWWBZZ17SpMHu+h4Bxy62+AbPHKGSujEGQhWq8ZRoJAT -G0KSObnmZ7FwFWu1e9XFoUCt0bSjiJWTIyaObMrWu/LvJ3e9I87HseSJStfw6fki -5og9qFEkMrIrBCp3QGuQWBq/rTdMuwNFiEkEGBECAAkFAkXwb0sCGwwACgkQoECD -D3+sWZF/WACfeNAu1/1hwZtUo1bR+MWiCjpvHtwAnA1R3IHqFLQ2X3xJ40XPuAyY -/FJG -=Quqp ------END PGP PUBLIC KEY BLOCK----- ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx -BOsOZ6kNFfBfjAxgJNWTkxZrHzDl74R7KW/nUx6X57bpFjUyRaB8F3/NpWKSeIGS -pJT+0m2SgUNhLAn1WY/iNJGNaMl7lgUnaP+/ZsSNT9hyTBiH3Ev5VvAtMGhVI/u8 -P0EtTjXp4o2U+VqFTBGmZ6PJVhCFjZUeRByloHw8dGOshfXKgriebpioHvU8iQ2U -GV3WNIirB2Rq1wkKxXJ/9Iw+4l5m4GmXMs7n3XaYQoBj28H86YA1cYWSm5LR5iU2 -TneI1fJ3vwF2vpSXVBUUDk67PZhg6ZwGRT7GFWskC0z8PsWd5jwK20mA8EVKq0vN -BFmMK6i4fJU+ux17Rgvnc9tDSCzFZ1/4f43EZ41uTmmNXIDsaPCqwjvSS5ICadt2 -xeqTWDlzONUpOs5yBjF1cfJSdVxsfshvln2JXUwgIdKl4DLbZybuNFXnPffNLb2v -PtRJHO48O2UbeXS8n27PcuMoLRd7+r7TsqG2vBH4t/cB/1vsvWMbqnQlaJ5VsjeW -Tp8Gv9FJiKuU8PKiWsF4EGR/kAFyCB8QbJeQ6HrOT0CXLOaYHRu2TvJ4taY9doXn -98TgU03XTLcYoSp49cdkkis4K+9hd2dUqARVCG7UVd9PY60VVCKi47BVKQARAQAB -tFRHb29nbGUgSW5jLiAoTGludXggUGFja2FnZXMgU2lnbmluZyBBdXRob3JpdHkp -IDxsaW51eC1wYWNrYWdlcy1rZXltYXN0ZXJAZ29vZ2xlLmNvbT6JAk4EEwEIADgC -GwMCHgECF4AWIQTrTBv9TwQvbd3M7JF3IfY704tHlgUCVwyM0wULCQgHAgYVCgkI -CwIEFgIDAQAKCRB3IfY704tHlkGrD/9aIOPxoABbhHDa+GbM1XHSeV99q2UOIsYc -A5Jg3k2+Vbjr/006cL9Kk+rdbruZJtERo2z+HVVhkJisvySbsd0UbWfiY5AdHzNP -azpitbX9cNYi0ghDZsD5UgP3cWdx21BJPO0v9PBG9U4z1TQ+pmsQphtNzMC4tK+A -H/7WTXnVPzKXTYziIEIPgHeassSj7Yfwa8kLiBR5tAehHDNNMi/mMf4d6a+wO46x -hhRx/BLjoaIxsZw9f5VxDAqGbCrW8IccwJX8vTc89y+6vpzSurdqYrplZWGpcnfT -3SPBxodLhS7wMehdy6NKNO14vDGR/GP43+6oZ91Cyv2CYHSPpZM6+qMwMmGVkHS2 -6PrCVPhPoDywf/7UeFsC4KZMI6LIGD2YI9UEOlcCAEbRwWVjXCSwRZ9vRkxOxK4Q -xNMLAIf3YmUZPnqGVcvNssgsapvjmI3CAWpAPWlP5GTcHxrVGiYz7hNZcA0PfgxF -pmB0QXNxr/x737I9Q8FCZasSlNqocaiKF6gKBxFOKfiKx5DRZ63EZ07Z3HE6y+w3 -+97UIJhjxVrONgb7ZX9paE8NtLG/X0ZldUzqWngfnFVasnCDiQC+ls2Tu9Oa+yMJ -rMe3VM4EcZTjYoESUjKzEHP72hn+GoAk7saWWVK6xYUJPM18Ua1mGx8xwoXt/t95 -W40b92HbJrkCDQRXDI3IARAAqy/YB4Xa+oEF+GTAObJaetvMTqxwrHSzueFjXT0S -nhR1yakkiYt37PBcQViOBZ3o3ilBmxfjKzpRaSqhC8WjI3u28Gcmqd4s87WR7Mz9 -2JjqEwSb0RBinQpC/NnC7AoWA/z64BPHK75IUp6vXr3LCgJ84jMYP8AwgoVC9xL6 -qNvQXqAfNX/hPcJK1EzAk/5Fcbd6RkWpSl9FIa7Sq6ZvMkX47nyX8I5HcIL4p5ER -mdhq1h4+C8zG4vf7nWGiWeumMNIRFOFEsVAfbzbZkha2+BAfdU9q4XOvHYEOI2AS -OyuBG2/F2lgMW/iAKt9ZdVJIhAN9heKlDKC+qwoQeMupx8Tp077PlxG+UwcF1aII -y0Sk0LOVPx1fZe4/hwHIZOct4ptjdlCpjMR6qLbz2WVGT3WgkcVHnUH/YEdMi2Vf -lPQXA7sI8y/8467YTWWJRBieh2f0y0k6eHQx/rl7i6jFVsuYqrirZ265zU0Lb+bc -A/gI6YMutGCzifWGoieBo4nzqc0pPN3tayd6f6V+geTVkIp1S2Sc8cnjqId4jI3Z -gg0pxFy6wpmL+YOo8lf1m3eBmBbjCvE0+/j0HVi3G2fy8XOcNLPnO/n+Tn5ilzuS -jx551LKxeQwWikT40nKcHj0IrcXiIJVIBDA5Da7gYbtT8wsXdwbV4Lvvit1naB91 -XIMAEQEAAYkEWwQYAQgAJgIbAhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJXDI3I -BQkFo5qAAinBXSAEGQECAAYFAlcMjcgACgkQE5e8U2QNtVFBJg//QTCvdPt7SyhP -PyDhAkstWpkNl1fwh7PTiJ00e68C7QDB1nbCXQL60yQPuXhHZojoEp7/3A+d2T80 -l75lhwP+7PKIoglAPjw+uJ82fC8e70DzSsTgGmlCemUQ16GJttZoY0lA40YUnHtB -NiUWNLks2UbUBfqZCPG9vjbfM5ZI6YRqZhdgGZjIwbq+Sv9dM/OyV2TLxcW4+slR -myUv9aXHfVdDUiu2Qcc5ipbCvSFNznT/Y7wfR7CX90FkurcSaKdln62xO6Ch/SPh -JvFiGmXD32cbBs3W5fLgvz91Y5Redjk6BpMpk8XXnNEzFc30V7KUFVimnmTOt7+t -EjqZDaVp9gd1uO93uvIcXkm9hOhINd3SbMXacvObqPCw7zjtk13kZ1MPr+9x5/Ug -m1rWdLAD+GEu2C2XPr+02dyneUR0KMAzHb2Ng8Nf4uqz0kDFwke5+vzajrAz1MXb -hDytrw1u8Hreh1WJ0J+Ieg6wgUNStrMfxe5pDPJmQjRtvMuaAwC8w7q7XM9979Mr -ot0mDsB4ApJw4lLfwPmabBoPVsAGvrt5sD9fkd1qiZIMpV1Rhp7B9MYEiytaYKYq -l1v5Z9fih0Wk3Ndb+qySIGnlZJ6wq83VBSQslkNkPWTPb75e6XkH3uzkvEtMtHC+ -Aug1pQWveWd6PM0uB0Gl/oWeQDn2zJEJEHch9jvTi0eWVo8P/2OVSzfPFfPUhJSw -zmgNX2WsW6WN91wtbf0oUpORK4otjJETUTvurVHPin473mSAeIypzMO1pHS6Q1uy -Pj5Em8x7BgGza1hBLUTvTIpRfS+J54hoaQL6XGnrE3/QIl/AxGK5aqc9h7EqsTbh -Pckg6BELWueKg1PpCGWtQ1igCcsTUt/kgJ54TjT7dUyuFCAapVgY6lMlEta4dIYJ -dbeQWkZR043o6u7R0HvYHl0P13thD41guhdZsPNah6km5hd7IEXuBNo/HReSHniI -zCKolpIkJyn9X1g+SKJ5aQ6MvFd2L4pkqJKt+nNvkoQXITw9yExDHJSQChX5Qnwe -eJoU0S2Qc6W9jL9qyOw3U+su2/oPzTk2xRu1CwiYLeNjZSNYhU9Az78CsvNrZUUK -CmiZrkmN8tRlFFps3TaF/fodwuYfWPC/R9WpKbtaqjjz3PqXHYbh5NyURVw/EqvM -y1yP26PsQn41tE5Ebndl6P2YzjAZQLKNTc584BXq7Tqj55jeeH/sS2XXv5gF2S+t -m9+Nwyuavl1mC5CNaL+KbkX6w/OadINUOArQW2HC1SwqP184fN9cJCx3NeB24kKg -84M42qQPUOIHfiu0R06JKaPWibk9WAU6ssQLcrbRs5NZ0ySqJWU0tpS/W4Zlz1Yj -Ytnce0VAbz25OAACZ0adKnWgKv8OuQINBFiGv8wBEACtrmK7c12DfxkPAJSD12Va -nxLLvvjYW0KEWKxN6TMRQCawLhGwFf7FLNpab829DFMhBcNVgJ8aU0YIIu9fHroI -aGi+bkBkDkSWEhSTlYa6ISfBn6Zk9AGBWB/SIelOncuAcI/Ik6BdDzIXnDN7cXsM -gV1ql7jIbdbsdX63wZEFwqbaiL1GWd4BUKhj0H46ZTEVBLl0MfHNlYl+X3ib9WpR -S6iBAGOWs8Kqw5xVE7oJm9DDXXWOdPUE8/FVti+bmOz+ICwQETY9I2EmyNXyUG3i -aKs07VAf7SPHhgyBEkMngt5ZGcH4gs1m2l/HFQ0StNFNhXuzlHvQhDzd9M1nqpst -Ee+f8AZMgyNnM+uGHJq9VVtaNnwtMDastvNkUOs+auMXbNwsl5y/O6ZPX5I5IvJm -UhbSh0UOguGPJKUu/bl65theahz4HGBA0Q5nzgNLXVmU6aic143iixxMk+/qA59I -6KelgWGj9QBPAHU68//J4dPFtlsRKZ7vI0vD14wnMvaJFv6tyTSgNdWsQOCWi+n1 -6rGfMx1LNZTO1bO6TE6+ZLuvOchGJTYP4LbCeWLL8qDbdfz3oSKHUpyalELJljzi -n6r3qoA3TqvoGK5OWrFozuhWrWt3tIto53oJ34vJCsRZ0qvKDn9PQX9r3o56hKhn -8G9z/X5tNlfrzeSYikWQcQARAQABiQRbBBgBCAAmAhsCFiEE60wb/U8EL23dzOyR -dyH2O9OLR5YFAliGv8wFCQWjmoACKcFdIAQZAQIABgUCWIa/zAAKCRBklMbWmXwh -XluJD/4mavm5UQ84EczsNesfNL8gY3zzlCnfvnUlJHK+CoYub4wcoDXVUlnCmWgS -lZHQZgr3/qfW2MM3y/kXcbxhL/FijUzY3WlnCdnIVNjuB+QJt0LHbkP7En/o085Z -zHuzaXxfZ97qN+KPsRBTjnJ8hd3B64cVjgnXva1+pG51EK4iDF2bXiWPHvUbPiL+ -Og6C9XjpWrwIA1CWyH/4i7dtfTnbViO2aqKQNHfrXJ+xS938Lr8r5+VmUWByHqwe -BGIASOmwsJeSUHozkZYbmMdaJJ8j458zyfS6LO+HIa3+zhzidOoiEH9c5QvVf54g -NsYjPTcHj7U0DgkxCVQeiBKBLR+q6M6QHa4qax/X0Z2ZCcSDTZwqGJNaKfcFYd8X -1B2zgrxkGweeHKjfmpqfXRKrggHumLdVqHU7KS9cz1yeTL+Nw7ne+kzRMEA8sLnm -4ODRUJwUz12RqS0GG1FYV0rjJVWVzRFMfMUs+7xAptEuMdoddkQSmytkXyOKAqv8 -KQ9XUEbGWikmCxW2cOY9spOpwQa7X2oXe7FlV9RfmHYrG03k+YlIREgFqlvWwsgp -zURculd+CIFvT3vci7vFm1UiQBb5wC8bHOoRsr7OXW1267lipouZr5OrQhVnRZQV -a64cdUIKjLXEt4790uxh8ggNwktZRILIn2JHjgEQICdYWeQb1AkQdyH2O9OLR5b3 -MA/8DRZi0s7SLQwaQiJrT7GrACsIMjYo6SapUVxDMF28QfANW809ANpq2Let+yAD -mEibSgpiDiO7rq6PvYnHmPyxmTbEwMtm1bDi0j55/TybnNN6hnUo8F+o0ywCJjfo -T8GDuBX50ODoOYUMmIoYwyMz/UtNi8iHtxTBPR5b7l1Vt8EfUb3wrwGa4i22mjgL -KU49h7Oyi1VYZRrM+0hlrmaLF79tT9msDnn83mgq9qefkJuU4nBqUXui/CY5b8vJ -XC+8tD+q1wCiUM8uv2LJs/5JyK80zFJbkBXA/ZCYtU0LJEpUf7HjbIAdCMDWjpc4 -j+IyjU+Axv+NkMLgYRhaadnPRVzqY8f2T2Bs+EQWk2i61BVQMqakGtwBWIMCp2fn -GDCxIL/FCN1kIA0J0h9ommhMgZdOJaAktsddr/LwVh/hcYX8Mfy94vPs+E3Kb6Oi -iwPkkN6umQvdFa9Rhh9SUNvmtXzMo3WELLobtvVKC+fdFVatDsJurTRKLDKEvPjS -xFlJ/T8t9yItTBAZ7+ab4nJhWoEbzkVTgNizLCJNmdAEtiKa9dEZOZl0DVmxBhB1 -aqMfHA3S5UhZXmGBHwCF6PcpnM3C4XY2MjQ/sRxdFa7/HFBKOO176h6HyujQ/AyO -llmvJCCg9Hz0Wk0tjTMFsnAbh7dB2GTNQwBNZ60gUCWR+mG5Ag0EXTX8rgEQAKyR -kvTxyusp9fZoPbDw5RLeNUZJbsrXQmv92CXpkHtfH/Ldz2WEGKbuhEiyXq2lH8ME -/nRSdMiAFu/Kdsnq1tYam23rgDOcjt6X2kfSTrcM4px+pFSAkpMzg5RlKRy6pDaq -eS+f6DSiIndWFpVg4l0l8kX+kuPk6LdQQvZp+gR3Tjz+VkRoBNG8SouP6HalJ8RM -SXnAJbJGe4xK7prL02ZXNHGImE8MZbamlBPEm5oqP7pWrDlYhK72exHFM8TUNbx/ -stjI8HCC6W25JgpmgJ1+hgTx9/jvWhki4IpwZJIEdBtHowFMPoom2rMHOl8nzNkm -ZU7iWDQImCn3FfZBnyE+SloFuerYkIxLXOuIIw3yIaFbpkdiZlAm1a65u5m3nVUv -1CYRRSEIXW37eV3XVJqjBjg0UogtR1hsLbMA5AgQQmRZEgcqV65zbNhI1KheXTqg -aDAIpBvmX4uVxgfHj78Xf4rPICrQ2oELWsyeFufe1xyR1nKEsSmfH3/LffKmjpln -Szp0sauZKkml50TPrOvyyIFri5Pci9UXjGN+nNK3dwwP8vOFueTmidR+SagKZD+m -S4qkyvfmEe10PGyEtws8WROdwyMRUA4FOgcNsoNKmW57ImbjwQs+L1ma7I27tawH -xNZUQCRRKHF14cAtWljUP4yNcr5nlqnr+2mmP5+bABEBAAGJBFsEGAEIACYCGwIW -IQTrTBv9TwQvbd3M7JF3IfY704tHlgUCXTX8rgUJBaOagAIpwV0gBBkBCAAGBQJd -NfyuAAoJEHi9ZUc8s70TzUAP/1Qq69M1CMd302TMnp1Yh1O06wkCPFGnMFMVwYRX -H5ggoYUb3IoCOmIAHOEn6v9fho0rYImS+oRDFeE08dOxeI+Co0xVisVHJ1JJvdnu -216BaXEsztZ0KGyUlFidXROrwndlpE3qlz4t1wh/EEaUH2TaQjRJ+O1mXJtF6vLB -1+YvMTMz3+/3aeX/elDz9aatHSpjBVS2NzbHurb9g7mqD45nB80yTBsPYT7439O9 -m70OqsxjoDqe0bL/XlIXsM9w3ei/Us7rSfSY5zgIKf7/iu+aJcMAQC9Zir7XASUV -sbBZywfpo2v4/ACWCHJ63lFST2Qrlf4Rjj1PhF0ifvB2XMR6SewNkDgVlQV+YRPO -1XwTOmloFU8qepkt8nm0QM1lhdOQdKVe0QyNn6btyUCKI7p4pKc8/yfZm5j6EboX -iGAb3XCcSFhR6pFrad12YMcKBhFYvLCaCN6g1q5sSDxvxqfRETvEFVwqOzlfiUH9 -KVY3WJcOZ3Cpbeu3QCpPkTiVZgbnR+WU9JSGQFEi7iZTrT8tct4hIg1Pa35B1lGZ -IlpYmzvdN5YoV9ohJoa1Bxj7qialTT/Su1Eb/toOOkOlqQ7B+1NBXzv9FmiBntC4 -afykHIeEIESNX9LdmvB+kQMW7d1d7Bs0aW2okPDt02vgwH2VEtQTtfq5B98jbwNW -9mbXCRB3IfY704tHliw+EAC5FNOwkABxZZ1C8K4wUDl2Oe7mewVRhVNqvTWS4uib -vFax78HDyLNqKmfi+yRHSQsDAkKr9GzmBc1DOabp4V+IRwj0vADHbcpwoGM7EJ2G -o/0RtdZiTP98B8DMACu17NwjM1l5EUExqjGEeXp3jEZGMSE8vqjq8djkvl8s5mUM -j09Wpj3Gl464NNQ/gnB0P/2sp11T0BVb2u32zNLJKh0ZP9QxXT3z93UBOeiT9BzR -hqFMyl04xpt5rqYDUdiL7y+tZDR28INZZ7aYsCs4NkA22Fh6nI3v43Us38+Kroru -09ipLE8A5fx3G5LxMwtWJA+zZisrrky86JYEFOULGpFuKrklP2bRyaHePjMeqOzD -Y5/n5unqk4+EZAPWIM4LFOwDtTD1BWmuDdpP/RjPuPZUhoMSW0p/Vv/FuBAnpgVQ -9D/kXI3xaAxKgaPp+AzQN50dCosmn643zAGrZTiIDIp1VtXVRFAVinN/mbJkqQJv -8zM/x0bc6EUNb/K8BP/JJp+x5D13DjtXYUEG8TFHz6YKZe9QzlhK5rZY/Fttwqvy -KvIKanXEjOf5/azkdOGlSN6Z74G4l22tui3y3CM+vmRrlMiBbLkCTuPfw8rS6uzi -B5No8PYBwovbqNvpm+dGNHySFTvNyJhzWmvCVt8FZ+c4tqOmwd/D+fhon0Pg42bu -+bkCDQRheAyfARAApNhsGrvrP6Spjk5xizJwd8m0LIlRi0YbMNkqkk70sgbYQMlt -VAKnUajQPPxXTJb1bqaRvPrwi1z5qT+twvvTNrckHjkdmlUKfrtRCMDeJT7uMK4e -r3bYEkYpvLsQXSyBxtes9McVYRNqzPzrf4LnH5KaBMNvPVWke7D5iMX1U5tUHKgh -ohUJd62Z5mugc/FDlyaBPMDviyuVpHHZhc+vmdwS0m+SC/ZYbAKxU6DauXTdkkk2 -wk3R0c60bqAnXn2B3caCwjOJCX4IEUYFoSqBCa6PmYqREqtU+ch1f4gCcvtw7gvC -22C77I7fVWWAEcPMSBm/dFY904VrjKFa/yFZik+36AuVoXtD0yP29n6zWlgscQuH -EVcTLrIgV+upnJUODL88I+dBtVisoFC2HLz0PNU4NKb4EyqoMcC/ZbjfTIg1bZJ/ -QmcezRZbM1a/onO51SYwDZyXmxRwhGXyW0KOLiMCn2G4aKVJAmuNYl6XrG1cwCqj -cHj4MjUwDBcmJ4wFBPBVVJse2SVW9eYhGzLN/ICSif1m/MLSUX5QH5IaxM4dTP+N -1lAFN0Xz5l06xnsgwmCkx4l054++PLh+lONLAfavqnhIWXU49Crn44LVmhVrGU5F -a7RjmiOsX1+qcv5N4Y1N3rPu3XRJcYTwXKjRN6ZD0am/cM/nsUnTO4YlMzcAEQEA -AYkEWwQYAQgAJgIbAhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJheAyfBQkFo5qA -AinBXSAEGQEIAAYFAmF4DJ8ACgkQTrJ9sqO4i4uCCQ//Ug1HJFOguZjWaz0NNYxD -SXBsEvwnfG7+d4og4pUY53D3NxaUa6BSg62FJtPxuO+7JsfVWPHjAUz5ye4xV+MP -nxe7pmmAIc3XBdgy7NjB4EUpoyDihLBMq4AkEnYiF8Sb9wCvJW8pjbNj67LOCLPH -e8CDeyOQA8NytIIk/aeS4dwnefNRso0COZ0yydYOuqplXA/32e7IyTxsC255nRIq -8ikK/bAh5g7vOSPrW+5A4U4aGX3w4G6LnBSG2BDD/96xNZiIY0pKYPd16t3YkdUD -TW0GYJZXgowsNuDcJwwxDXHdXWZ7oQbeCLAEvUj3FOwFRsRrp4Q31TTN0q+gxtKi -A43nAK7EDM78JcYyt4m0FS6kcRzr2hO7B7jboiGLcBtGs8CDe2cYYUK3XUehAU2d -E9Zve6cXxSUDatLK2/AXJCLenMFi3lWxMgDs0Qca4mz786ivoA4ifOG3VynsB+YM -Z8bLY3mjD7gYjoU97ZSoiDb6cWIav2FFk69dGAtAvx2UOcUKHKaV3Gb8n9QV0kZJ -ZGV0QOw+vMdARIq+xX0SOclBHmnnORArqPHTOpKUOCI0bYZPf8JK/Ah0KKHoKX0d -OEe1g2bdlg3RtT1baN6guHcAg01NyunS0Adm5AsXG6RuPno7l4H6d+Trv9faI2KL -jpl0lA3BtP1g3oKy1DP4KeoJEHch9jvTi0eWxrwP/0zlWCYOsNH5Id4SZsPKe8im -evCbj3lvboTYPc4u6HvbbwbYqLerzP2ajWSCdUAK4CMrAuvFildo4k6COh6VaZdi -DOwsKoJfs6Vd5oud5a+jRnv8+oktRBf5OAVc3RLfBG1RC9qI891JTOjGrTU7dBJr -RjRWdy9YQd/epN2I0RVtUaJlxKELoFj57FPERZgg+yomiheBARK+fLYY/oFTwJK3 -+Kt3rdnBtUeVpEiL6VjU6bqvIpUG+P0u27AspcacgDewg59+thcbY4tnsdo6DSZB -Q92bBPVGzpXPEhpQ/vZM63CG8qsZfQ1jw82ovmSnkKPLnBQRabFYVl0DCl1uYHg2 -4Up66w6Lj/tT2XbCeBf2n54K9HoUMV9f7/pLoTa0dE3UYI1K4GLZdp+yxMveUEjG -nh0YOTBmoBtpdy6Udejujil6xbH2gLwbICFm+boKVWwzrYCyfl51ASiq5dmqQwd3 -tPAg9Hc6qtvZ8cswyWyNOQpZo0myvfPaKrHWa9u2GqQmeGBwhckXJxFM/zau0yx6 -NMkSFI49kTglw0A77rcmlJUAQQeoXmTKMl6NM/3AUfvL8Qfu9/74kgoFI9pmQFky -BtcQMCeB2/JQ9K9ywPhi/gIebjftfMgKQsTW+/6Nl1yZ8q38y2n1J4p/acVlFc2K -PhbmKL4CvcSdlQS4CbvFuQINBGPs+VgBEADKbgLL+vAabKV2rGSDgY+IttTAtg9w -9Uor1+Q/CIWGxi/JQy7l7XTKjmS0wvdwU+9f/eGsjxigbvAcSsV1szyKfVQQFT2m -9KhDrBqNCAvQ5Tg6ZQdNe51oHwjiIQ1i7z8QoT22VucdTYqcMLAHe+g0aNqLLSSW -LAiW4z+nerclinjiTRCw/aWZJR1ozQd2eKwAw6rk19bHcihXo2E0K1EDmdHcNA8y -typxwWWXBftCYRWXi5J02GeZazxmx/DULnFgy2J4G0ULTqGWsbf/tCt22jqgyX+v -Fj/sJPn+l3IJqpyNY5yBG6GcejeP9vRoQrapGqHkcx+37f2vjwmpj5548JI52KEC -1yZeFwp8HjGLp+zGajpnokrKd4XJHniW9+bPLq7Yp7PNn65MaYvZUjv5enKd45fF -K6vJ3Ys/fx6PBXKKBs9flRIgdXOKSvtV+bGIG0I/p/JEZ/wPxRgxHPDK5jbcI6KB -Vm3Uk+CHFC4IBAtzdSh6H4Zfw1EH3dQZMLVBB/Sj34UQhlwAOlAXtZH3vks/Kpcl -WK8gnqz3i8HN0ezvcnQlRiRO8IqlN9/PmFqZeNTerklT7Tt0jXqiopLHL0FXR2Ls -ndeORfxDE1rhVOUxloeuIsY8x6gO8h2bGg41YapROjYxZZEcakg9Nch4XAlxeqB4 -ISttfbiVxeL2DQARAQABiQRbBBgBCAAmAhsCFiEE60wb/U8EL23dzOyRdyH2O9OL -R5YFAmPs+VgFCQWjmoACKcFdIAQZAQgABgUCY+z5WAAKCRDoiXn7mzCs8kblD/48 -yE3Wpi6Cw8RBzq2uzLdkuqXh691zG6VhHUZQNb85ewGjGDu/D25u2JFrhAcmlzOr -xggvL4a8WatPXQaPqDZaSh41elM1Ya0C7cNQq7xNVA0pcN5bQ+KXXZMuQaA89BCl -TSXITz6j4O4pvhAG8y8Q2E9Mv7UYas0OhDgzVIry2s1o2Pml1qjlb9jctO9crRUi -F6v9Ru9aQkgGHYt4uyP3HzKDfoNuzX/WX3O0Fm8NNpnJk6qZsLKwg7ukUdJOIEIb -LLNLU9ZYmys3wNtDKMfm4T79abSNwNIn4dd5hapH9BAuDJnk4WnFOap9AQZPgJX2 -WXKC2DXQZeSX1VXpI3rr7FSbSec8d5bitw7s20XWyQB2+ZoetRxNgR104GIh/Laj -tatLKFc9NnP9Smhey8nrxVZFx6HuXsnGOPkbjsiFYMsxtPVYnO72nBDTDP4ZejLO -aay2KtCb8pJkCH8U0guquDGVd+S02Xx947evyvHqGt5V0yVFPD7uAu7A5QBYXvtc -tzq93S1jZDIoMP93Oe8VpUrXBBfizzHVxP6VUmxM97IE+gjVRqN9PuMrp2D9yEBU -Gk44fQW5zyuuomYac7Mpx2fnWgGA/Al9ug2uvS4oIzUyLEJxpc6M8RYluacSIjFg -CigucRsvTBy6lobG1FMvnQyze6+fAeKbbrK85OuA1AkQdyH2O9OLR5bPGRAAmgSi -hpu4US/JoWnR/aeiFf9upobXVDnBnqOAXiMUaFeS+hUuh5EWUhDLIWYvXXhPacvb -pUOlxwLsLIdPRQGGSp1/rqhVRnmWsJ34DoAKxG7Elq8EArK/pF+v4wSUMegjAPJQ -evIcLvm83z+jHmbk1AEeioBYTq45RbzlHmyLmGK/zT13KnBUWE3sFkECoco+vMli -8oPeL+JMfiMgPb2vDs+58YlHq5W26pe08BwGzY5LQM7Jt52oxsqgXEX/N95QqgSc -sc625wCIE8/Qo5pXT0TKk+5ViFojs2Ei3mgXHBXFgISdAtWBEmqN9TESqPPrHzfn -Fk9t6mPg1r5Nt37IKO7oTzu7/SXrJlXPIQ99Nlq6HO/mMVdYjbWFBPw8+NGVGemQ -chOODZsksvHJGV4gjMpW1FC37MRNsiai1UMraVxzsrCte4/oqpa7bY8VdWw6p5mv -fdroLkwHW2cS2lgC8ft7e4npiHXXLAIib+sFHcrIkZu0uJxGCJOkUwkaDrAFKWzZ -YHc2YUrW5XN7CNBo/fe90r1W9/4esn59SM2mTMarrUn1fiExwFiUci4U+3/7U4Ii -ViNeNoZ2J1+hqxudlx1OT7Ae2Wg4dLASoEHaMKby4+JVVicA8jdlocrCbpEv1hVV -47hwiKc+VTQGvCZqs8eT+pbnw1Recd13J9Ny7bO5Ag0EZbladgEQAMSm1QPtyjAr -XdM1i2Y6439Jc/AJy3ykVjxTaDi6n5z7lgQipaQBSpWbwun4Op0W5fs1t8rYE2iP -A/KKoqVoEA3o3Hts71uNK+VttkGtUneYv6TvGsV1MYt4NJJOUQF6yPsVcrXMrtJb -0BXefjmWY4sBdMLXdVDcrRIRdv7r0XBevfX+Lng2BN8z/UtwlmEihHoy60ckJJgq -47pkfFho51+PjwEZJaPtEgRsXn2sgTMNHukGTrV8ub/aKWVNBPF0wYYF5LA2NHgV -p148nS11F4OgiNpCkAZmJQCPlyp4emYfxkihjh+TZKw6KcrxwOCx7YeceKK6wWvr -HHrwjJxl2nhatDIYNIlnVkqTlBp4A9gTdCxmciZ1xXb+QllLycBYMWgu2lo1Kk40 -NOfVljIKLatY88XwmJUySYLGyX5kePI29kc+yVGycYHsSgoOlyM/Vw+GXfuj/BRi -nKItjITxb6YM25wfhgctUer/NAao7dXprFMDUOz6C720dX/f7ISsiqmi7X1U588o -mNgLvJ/O8gPnyMtk1gWrwhFZDlVYI5AlYxx3MwoHntLZlvm8iEmR+X9LkhIwZcNd -vfafIpV+8LlOaIxt+uzNzcMsDHCGomUAf/GYXbI8/x1iHoopZIh99UZObfyxyz2S -SbVtUEBHXyKXHp0bFWM1Iz2LfQwxeNRRABEBAAGJBHIEGAEKACYWIQTrTBv9TwQv -bd3M7JF3IfY704tHlgUCZbladgIbAgUJBaOagAJACRB3IfY704tHlsF0IAQZAQoA -HRYhBA8G/4a+6vTnGGbuUjLuU1WmvG5CBQJluVp2AAoJEDLuU1WmvG5CmB4P/1Rn -XKHryp3UlaOAq/UAF2YKFS9NAggVwH8PhsFc6nZpruc+CFU1s5jwCuW9aiWgQ+Tj -BFvQ0h/bHLbujlTSmfyyyo/Ij+4vSxRzlmUa8lHPqyqv7fIsQ82AAs8WE/mV8Dif -24hsxJSZEH130DTkRqtnXS0FB6sOQPGj5EKAFt3v0vN/Z1QRX2eLmZc2jO7QfkdR -strvF3borb7xdt26/PM8g8RgYaG+fqIJ/NtGQF0XI+WUxuQ+mtRGEyVpL4qnwwno -kyxjsMxsJvvGIaPULKR1CahGJD4tAlyE3DvNikMRI2SDojaGyh5cw24mJJVZmx46 -7Q3tE4dwmAu8pCGCldUQBG6eprTL/WauyJcmkJr1qsSK7gyx+Uy8mwXESY/s5bwD -kzhlzaJ0WjBxqXfoHFIElHJfhLS0efqIr6NFmPUu4cBKJKoZoFBwTPTTEmWz7tE2 -mDgVO9Z6Q9fq7CwZS6J/GchieQgAy3Rxm5BizBZsWisY3BQ4JX1w6wH0Cae4rYCe -bkutFFWBg7JA3j2nkgfzsD3kYHYf5BllL2yV589dEocNjPios56vPi5kg9UQOFO1 -SaX4Efu1eArNcNteBxKf5pH8okDcgjqj9yXZRs6fI2Uk9zzz0UL63+iRSqSj8Kv6 -iepLCzOph1DHnY2tFghpSFYqlayhdprMJVk7GmLFoiYP/1nT6wq8k/RDS3/W7HEB -J8Rtxs1vL51nU0e5K7jgbUT9kaG2KBmlnRbgkELjvu0lX6zLFiyPcc5JkvE2AyfZ -7t5cIfanOS4hc0W9C66RQo2cvUxkn2gtCrM7KCTc16Iwe/uMC2RNEneNLiCetwc5 -DhpjYExR59szzQ9Npx31pefsmkSwKdutEz8W96l29yHYgIDoLYW3b6nuBRBfp4nA -XQ1gWqfEmFNFlKZBa2pPsKNlFgpchC+EiMQ/db1ElVNyW38K7IOx6hNGpEBJwbPu -HNef9WU3n2DIIgMBHTHPvbNHiCNTfuOM1+/BMbmK59RmW66TS0UaxZsswHHLZt7v -NN7SKzXsveT9+A1d6wZlVoy8Y3gykBKnBHGRaGO0zaXczHt4YsUA4L3is6lAjbIo -pU5M3j2F1RFKRr95+HZT/NXNeGbFvsdKmvP4ELtDAuYVMgYR8GqjI5yP/ccVMsi/ -mhT+cUxO/F7+7nixw1Go637Jqr/NF5kjjrBD8EiGy8QrGm6uBR3NGad0BnMWKa2Y -oYKF1m3Fs/evBkcymR+hSwFzkXm6WSOb8hzJIayFa6kAc7uSKyR5iG00p/neibbq -M1aUAQDBwV7g9wPmcdRIjJS2MtK1JXHZCR1gVKb+EObct6RJOVw8s58ES5O9wGZm -bVtIZ+JHTbuH+tg0EoRNcCbz -=JIbr ------END PGP PUBLIC KEY BLOCK----- From 089faf88031f1cb74bc03e0e1e756f001fc1a2ca Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 14 Jun 2026 11:36:04 -0700 Subject: [PATCH 02/11] new dart analyzer failure with bad override --- doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart index 5765f08bf..18efb5a2d 100644 --- a/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart +++ b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart @@ -7,7 +7,6 @@ import '../../chapter_3/answers/helper.dart'; import '../../chapter_5/answers/full_subtractor.dart'; class FullSubtractorComb extends FullSubtractor { - @override FullSubtractorComb(super.a, super.b, super.borrowIn) { // Declare input and output final a = input('a'); From 1232afbe490bbf246590886e2adae8765aba699d Mon Sep 17 00:00:00 2001 From: Desmond Kirkpatrick Date: Mon, 15 Jun 2026 06:04:29 -0700 Subject: [PATCH 03/11] Potential fix for pull request finding Clarify comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tool/gh_codespaces/install_dart.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/gh_codespaces/install_dart.sh b/tool/gh_codespaces/install_dart.sh index 3fc47fcd7..f170dc247 100755 --- a/tool/gh_codespaces/install_dart.sh +++ b/tool/gh_codespaces/install_dart.sh @@ -19,7 +19,7 @@ wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub \ | gpg --dearmor \ | sudo tee /usr/share/keyrings/dart.gpg >/dev/null -# Add Dart repository key. +# Add Dart repository. echo "deb [signed-by=/usr/share/keyrings/dart.gpg] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main" \ | sudo tee /etc/apt/sources.list.d/dart_stable.list From a87fa5c20118f6813c1f354731e34d6634060c08 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 21 Jun 2026 09:26:53 -0700 Subject: [PATCH 04/11] added back pubkeys, and made a wget a fallback solution with loud warning --- tool/gh_codespaces/install_dart.sh | 80 +++++++- tool/gh_codespaces/pubkeys/dart.pub | 305 ++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 tool/gh_codespaces/pubkeys/dart.pub diff --git a/tool/gh_codespaces/install_dart.sh b/tool/gh_codespaces/install_dart.sh index f170dc247..d0bfdfe91 100755 --- a/tool/gh_codespaces/install_dart.sh +++ b/tool/gh_codespaces/install_dart.sh @@ -8,21 +8,91 @@ # # 2023 February 5 # Author: Chykon +# +# 2026 June 21 +# Updated to add fallback logic for fetching the latest Dart repository key from Google if the locally cached key fails verification (e.g. due to key rotation). +# Author: Desmond A. Kirkpatrick set -euo pipefail +declare -r cached_pubkey_file="$(dirname "${BASH_SOURCE[0]}")/pubkeys/dart.pub" +declare -r keyring_file='/usr/share/keyrings/dart.gpg' +declare -r dart_repository_file='/etc/apt/sources.list.d/dart_stable.list' +declare -r dart_repository_url='https://storage.googleapis.com/download.dartlang.org/linux/debian' +declare -r google_signing_key_url='https://dl-ssl.google.com/linux/linux_signing_key.pub' + sudo apt-get update sudo apt-get install -y wget gpg apt-transport-https sudo mkdir -p /usr/share/keyrings -wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub \ - | gpg --dearmor \ - | sudo tee /usr/share/keyrings/dart.gpg >/dev/null # Add Dart repository. -echo "deb [signed-by=/usr/share/keyrings/dart.gpg] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main" \ - | sudo tee /etc/apt/sources.list.d/dart_stable.list +echo "deb [signed-by=${keyring_file}] ${dart_repository_url} stable main" \ + | sudo tee "${dart_repository_file}" + +# Install the repository key from the locally cached, ASCII-armored public key. +install_key_from_file() { + sudo gpg --yes --output "${keyring_file}" --dearmor "${1}" +} + +# Install the repository key by fetching the latest key from Google. +install_key_from_google() { + wget -qO- "${google_signing_key_url}" \ + | gpg --dearmor \ + | sudo tee "${keyring_file}" >/dev/null +} + +# Emit a prominent warning that stands out in CI logs (and as a GitHub Actions +# annotation when available) without failing the build. +warn_loudly() { + local message="${1}" + { + echo '' + echo '################################################################################' + echo '## install_dart WARNING' + echo "## ${message}" + echo '################################################################################' + echo '' + } >&2 + # Surface a GitHub Actions warning annotation (non-fatal) when running in CI. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::warning title=install_dart cached key bypassed::${message}" + fi +} + +# Verify that the installed keyring can authenticate the Dart repository by +# refreshing only the Dart sources list and checking for signature/key errors. +dart_repository_verified() { + local update_log + if ! update_log=$(sudo apt-get update \ + -o Dir::Etc::sourcelist="${dart_repository_file}" \ + -o Dir::Etc::sourceparts="-" \ + -o APT::Get::List-Cleanup="0" 2>&1); then + return 1 + fi + if echo "${update_log}" \ + | grep -Eiq 'NO_PUBKEY|EXPKEYSIG|REVKEYSIG|BADSIG|not signed|could.?n.?t be verified'; then + return 1 + fi + return 0 +} + +# Prefer the locally cached key. If it can no longer authenticate the repository +# (e.g. the key has been rotated), fall back to fetching the latest key from +# Google so the install can still proceed. +install_key_from_file "${cached_pubkey_file}" + +if dart_repository_verified; then + echo 'install_dart: using locally cached Dart repository key.' +else + install_key_from_google + if ! dart_repository_verified; then + echo 'install_dart: Dart repository key verification failed even after fetching the latest key from Google.' >&2 + exit 1 + fi + warn_loudly "Cached Dart repository key (${cached_pubkey_file}) failed verification and was bypassed; installed using the latest key fetched from Google. Please refresh the cached key." +fi # Install Dart. diff --git a/tool/gh_codespaces/pubkeys/dart.pub b/tool/gh_codespaces/pubkeys/dart.pub new file mode 100644 index 000000000..839f8a235 --- /dev/null +++ b/tool/gh_codespaces/pubkeys/dart.pub @@ -0,0 +1,305 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx +BOsOZ6kNFfBfjAxgJNWTkxZrHzDl74R7KW/nUx6X57bpFjUyRaB8F3/NpWKSeIGS +pJT+0m2SgUNhLAn1WY/iNJGNaMl7lgUnaP+/ZsSNT9hyTBiH3Ev5VvAtMGhVI/u8 +P0EtTjXp4o2U+VqFTBGmZ6PJVhCFjZUeRByloHw8dGOshfXKgriebpioHvU8iQ2U +GV3WNIirB2Rq1wkKxXJ/9Iw+4l5m4GmXMs7n3XaYQoBj28H86YA1cYWSm5LR5iU2 +TneI1fJ3vwF2vpSXVBUUDk67PZhg6ZwGRT7GFWskC0z8PsWd5jwK20mA8EVKq0vN +BFmMK6i4fJU+ux17Rgvnc9tDSCzFZ1/4f43EZ41uTmmNXIDsaPCqwjvSS5ICadt2 +xeqTWDlzONUpOs5yBjF1cfJSdVxsfshvln2JXUwgIdKl4DLbZybuNFXnPffNLb2v +PtRJHO48O2UbeXS8n27PcuMoLRd7+r7TsqG2vBH4t/cB/1vsvWMbqnQlaJ5VsjeW +Tp8Gv9FJiKuU8PKiWsF4EGR/kAFyCB8QbJeQ6HrOT0CXLOaYHRu2TvJ4taY9doXn +98TgU03XTLcYoSp49cdkkis4K+9hd2dUqARVCG7UVd9PY60VVCKi47BVKQARAQAB +tFRHb29nbGUgSW5jLiAoTGludXggUGFja2FnZXMgU2lnbmluZyBBdXRob3JpdHkp +IDxsaW51eC1wYWNrYWdlcy1rZXltYXN0ZXJAZ29vZ2xlLmNvbT6JAk4EEwEIADgC +GwMCHgECF4AWIQTrTBv9TwQvbd3M7JF3IfY704tHlgUCVwyM0wULCQgHAgYVCgkI +CwIEFgIDAQAKCRB3IfY704tHlkGrD/9aIOPxoABbhHDa+GbM1XHSeV99q2UOIsYc +A5Jg3k2+Vbjr/006cL9Kk+rdbruZJtERo2z+HVVhkJisvySbsd0UbWfiY5AdHzNP +azpitbX9cNYi0ghDZsD5UgP3cWdx21BJPO0v9PBG9U4z1TQ+pmsQphtNzMC4tK+A +H/7WTXnVPzKXTYziIEIPgHeassSj7Yfwa8kLiBR5tAehHDNNMi/mMf4d6a+wO46x +hhRx/BLjoaIxsZw9f5VxDAqGbCrW8IccwJX8vTc89y+6vpzSurdqYrplZWGpcnfT +3SPBxodLhS7wMehdy6NKNO14vDGR/GP43+6oZ91Cyv2CYHSPpZM6+qMwMmGVkHS2 +6PrCVPhPoDywf/7UeFsC4KZMI6LIGD2YI9UEOlcCAEbRwWVjXCSwRZ9vRkxOxK4Q +xNMLAIf3YmUZPnqGVcvNssgsapvjmI3CAWpAPWlP5GTcHxrVGiYz7hNZcA0PfgxF +pmB0QXNxr/x737I9Q8FCZasSlNqocaiKF6gKBxFOKfiKx5DRZ63EZ07Z3HE6y+w3 ++97UIJhjxVrONgb7ZX9paE8NtLG/X0ZldUzqWngfnFVasnCDiQC+ls2Tu9Oa+yMJ +rMe3VM4EcZTjYoESUjKzEHP72hn+GoAk7saWWVK6xYUJPM18Ua1mGx8xwoXt/t95 +W40b92HbJrkCDQRXDI3IARAAqy/YB4Xa+oEF+GTAObJaetvMTqxwrHSzueFjXT0S +nhR1yakkiYt37PBcQViOBZ3o3ilBmxfjKzpRaSqhC8WjI3u28Gcmqd4s87WR7Mz9 +2JjqEwSb0RBinQpC/NnC7AoWA/z64BPHK75IUp6vXr3LCgJ84jMYP8AwgoVC9xL6 +qNvQXqAfNX/hPcJK1EzAk/5Fcbd6RkWpSl9FIa7Sq6ZvMkX47nyX8I5HcIL4p5ER +mdhq1h4+C8zG4vf7nWGiWeumMNIRFOFEsVAfbzbZkha2+BAfdU9q4XOvHYEOI2AS +OyuBG2/F2lgMW/iAKt9ZdVJIhAN9heKlDKC+qwoQeMupx8Tp077PlxG+UwcF1aII +y0Sk0LOVPx1fZe4/hwHIZOct4ptjdlCpjMR6qLbz2WVGT3WgkcVHnUH/YEdMi2Vf +lPQXA7sI8y/8467YTWWJRBieh2f0y0k6eHQx/rl7i6jFVsuYqrirZ265zU0Lb+bc +A/gI6YMutGCzifWGoieBo4nzqc0pPN3tayd6f6V+geTVkIp1S2Sc8cnjqId4jI3Z +gg0pxFy6wpmL+YOo8lf1m3eBmBbjCvE0+/j0HVi3G2fy8XOcNLPnO/n+Tn5ilzuS +jx551LKxeQwWikT40nKcHj0IrcXiIJVIBDA5Da7gYbtT8wsXdwbV4Lvvit1naB91 +XIMAEQEAAYkEWwQYAQgAJgIbAhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJXDI3I +BQkFo5qAAinBXSAEGQECAAYFAlcMjcgACgkQE5e8U2QNtVFBJg//QTCvdPt7SyhP +PyDhAkstWpkNl1fwh7PTiJ00e68C7QDB1nbCXQL60yQPuXhHZojoEp7/3A+d2T80 +l75lhwP+7PKIoglAPjw+uJ82fC8e70DzSsTgGmlCemUQ16GJttZoY0lA40YUnHtB +NiUWNLks2UbUBfqZCPG9vjbfM5ZI6YRqZhdgGZjIwbq+Sv9dM/OyV2TLxcW4+slR +myUv9aXHfVdDUiu2Qcc5ipbCvSFNznT/Y7wfR7CX90FkurcSaKdln62xO6Ch/SPh +JvFiGmXD32cbBs3W5fLgvz91Y5Redjk6BpMpk8XXnNEzFc30V7KUFVimnmTOt7+t +EjqZDaVp9gd1uO93uvIcXkm9hOhINd3SbMXacvObqPCw7zjtk13kZ1MPr+9x5/Ug +m1rWdLAD+GEu2C2XPr+02dyneUR0KMAzHb2Ng8Nf4uqz0kDFwke5+vzajrAz1MXb +hDytrw1u8Hreh1WJ0J+Ieg6wgUNStrMfxe5pDPJmQjRtvMuaAwC8w7q7XM9979Mr +ot0mDsB4ApJw4lLfwPmabBoPVsAGvrt5sD9fkd1qiZIMpV1Rhp7B9MYEiytaYKYq +l1v5Z9fih0Wk3Ndb+qySIGnlZJ6wq83VBSQslkNkPWTPb75e6XkH3uzkvEtMtHC+ +Aug1pQWveWd6PM0uB0Gl/oWeQDn2zJEJEHch9jvTi0eWVo8P/2OVSzfPFfPUhJSw +zmgNX2WsW6WN91wtbf0oUpORK4otjJETUTvurVHPin473mSAeIypzMO1pHS6Q1uy +Pj5Em8x7BgGza1hBLUTvTIpRfS+J54hoaQL6XGnrE3/QIl/AxGK5aqc9h7EqsTbh +Pckg6BELWueKg1PpCGWtQ1igCcsTUt/kgJ54TjT7dUyuFCAapVgY6lMlEta4dIYJ +dbeQWkZR043o6u7R0HvYHl0P13thD41guhdZsPNah6km5hd7IEXuBNo/HReSHniI +zCKolpIkJyn9X1g+SKJ5aQ6MvFd2L4pkqJKt+nNvkoQXITw9yExDHJSQChX5Qnwe +eJoU0S2Qc6W9jL9qyOw3U+su2/oPzTk2xRu1CwiYLeNjZSNYhU9Az78CsvNrZUUK +CmiZrkmN8tRlFFps3TaF/fodwuYfWPC/R9WpKbtaqjjz3PqXHYbh5NyURVw/EqvM +y1yP26PsQn41tE5Ebndl6P2YzjAZQLKNTc584BXq7Tqj55jeeH/sS2XXv5gF2S+t +m9+Nwyuavl1mC5CNaL+KbkX6w/OadINUOArQW2HC1SwqP184fN9cJCx3NeB24kKg +84M42qQPUOIHfiu0R06JKaPWibk9WAU6ssQLcrbRs5NZ0ySqJWU0tpS/W4Zlz1Yj +Ytnce0VAbz25OAACZ0adKnWgKv8OuQINBFiGv8wBEACtrmK7c12DfxkPAJSD12Va +nxLLvvjYW0KEWKxN6TMRQCawLhGwFf7FLNpab829DFMhBcNVgJ8aU0YIIu9fHroI +aGi+bkBkDkSWEhSTlYa6ISfBn6Zk9AGBWB/SIelOncuAcI/Ik6BdDzIXnDN7cXsM +gV1ql7jIbdbsdX63wZEFwqbaiL1GWd4BUKhj0H46ZTEVBLl0MfHNlYl+X3ib9WpR +S6iBAGOWs8Kqw5xVE7oJm9DDXXWOdPUE8/FVti+bmOz+ICwQETY9I2EmyNXyUG3i +aKs07VAf7SPHhgyBEkMngt5ZGcH4gs1m2l/HFQ0StNFNhXuzlHvQhDzd9M1nqpst +Ee+f8AZMgyNnM+uGHJq9VVtaNnwtMDastvNkUOs+auMXbNwsl5y/O6ZPX5I5IvJm +UhbSh0UOguGPJKUu/bl65theahz4HGBA0Q5nzgNLXVmU6aic143iixxMk+/qA59I +6KelgWGj9QBPAHU68//J4dPFtlsRKZ7vI0vD14wnMvaJFv6tyTSgNdWsQOCWi+n1 +6rGfMx1LNZTO1bO6TE6+ZLuvOchGJTYP4LbCeWLL8qDbdfz3oSKHUpyalELJljzi +n6r3qoA3TqvoGK5OWrFozuhWrWt3tIto53oJ34vJCsRZ0qvKDn9PQX9r3o56hKhn +8G9z/X5tNlfrzeSYikWQcQARAQABiQRbBBgBCAAmAhsCFiEE60wb/U8EL23dzOyR +dyH2O9OLR5YFAliGv8wFCQWjmoACKcFdIAQZAQIABgUCWIa/zAAKCRBklMbWmXwh +XluJD/4mavm5UQ84EczsNesfNL8gY3zzlCnfvnUlJHK+CoYub4wcoDXVUlnCmWgS +lZHQZgr3/qfW2MM3y/kXcbxhL/FijUzY3WlnCdnIVNjuB+QJt0LHbkP7En/o085Z +zHuzaXxfZ97qN+KPsRBTjnJ8hd3B64cVjgnXva1+pG51EK4iDF2bXiWPHvUbPiL+ +Og6C9XjpWrwIA1CWyH/4i7dtfTnbViO2aqKQNHfrXJ+xS938Lr8r5+VmUWByHqwe +BGIASOmwsJeSUHozkZYbmMdaJJ8j458zyfS6LO+HIa3+zhzidOoiEH9c5QvVf54g +NsYjPTcHj7U0DgkxCVQeiBKBLR+q6M6QHa4qax/X0Z2ZCcSDTZwqGJNaKfcFYd8X +1B2zgrxkGweeHKjfmpqfXRKrggHumLdVqHU7KS9cz1yeTL+Nw7ne+kzRMEA8sLnm +4ODRUJwUz12RqS0GG1FYV0rjJVWVzRFMfMUs+7xAptEuMdoddkQSmytkXyOKAqv8 +KQ9XUEbGWikmCxW2cOY9spOpwQa7X2oXe7FlV9RfmHYrG03k+YlIREgFqlvWwsgp +zURculd+CIFvT3vci7vFm1UiQBb5wC8bHOoRsr7OXW1267lipouZr5OrQhVnRZQV +a64cdUIKjLXEt4790uxh8ggNwktZRILIn2JHjgEQICdYWeQb1AkQdyH2O9OLR5b3 +MA/8DRZi0s7SLQwaQiJrT7GrACsIMjYo6SapUVxDMF28QfANW809ANpq2Let+yAD +mEibSgpiDiO7rq6PvYnHmPyxmTbEwMtm1bDi0j55/TybnNN6hnUo8F+o0ywCJjfo +T8GDuBX50ODoOYUMmIoYwyMz/UtNi8iHtxTBPR5b7l1Vt8EfUb3wrwGa4i22mjgL +KU49h7Oyi1VYZRrM+0hlrmaLF79tT9msDnn83mgq9qefkJuU4nBqUXui/CY5b8vJ +XC+8tD+q1wCiUM8uv2LJs/5JyK80zFJbkBXA/ZCYtU0LJEpUf7HjbIAdCMDWjpc4 +j+IyjU+Axv+NkMLgYRhaadnPRVzqY8f2T2Bs+EQWk2i61BVQMqakGtwBWIMCp2fn +GDCxIL/FCN1kIA0J0h9ommhMgZdOJaAktsddr/LwVh/hcYX8Mfy94vPs+E3Kb6Oi +iwPkkN6umQvdFa9Rhh9SUNvmtXzMo3WELLobtvVKC+fdFVatDsJurTRKLDKEvPjS +xFlJ/T8t9yItTBAZ7+ab4nJhWoEbzkVTgNizLCJNmdAEtiKa9dEZOZl0DVmxBhB1 +aqMfHA3S5UhZXmGBHwCF6PcpnM3C4XY2MjQ/sRxdFa7/HFBKOO176h6HyujQ/AyO +llmvJCCg9Hz0Wk0tjTMFsnAbh7dB2GTNQwBNZ60gUCWR+mG5Ag0EXTX8rgEQAKyR +kvTxyusp9fZoPbDw5RLeNUZJbsrXQmv92CXpkHtfH/Ldz2WEGKbuhEiyXq2lH8ME +/nRSdMiAFu/Kdsnq1tYam23rgDOcjt6X2kfSTrcM4px+pFSAkpMzg5RlKRy6pDaq +eS+f6DSiIndWFpVg4l0l8kX+kuPk6LdQQvZp+gR3Tjz+VkRoBNG8SouP6HalJ8RM +SXnAJbJGe4xK7prL02ZXNHGImE8MZbamlBPEm5oqP7pWrDlYhK72exHFM8TUNbx/ +stjI8HCC6W25JgpmgJ1+hgTx9/jvWhki4IpwZJIEdBtHowFMPoom2rMHOl8nzNkm +ZU7iWDQImCn3FfZBnyE+SloFuerYkIxLXOuIIw3yIaFbpkdiZlAm1a65u5m3nVUv +1CYRRSEIXW37eV3XVJqjBjg0UogtR1hsLbMA5AgQQmRZEgcqV65zbNhI1KheXTqg +aDAIpBvmX4uVxgfHj78Xf4rPICrQ2oELWsyeFufe1xyR1nKEsSmfH3/LffKmjpln +Szp0sauZKkml50TPrOvyyIFri5Pci9UXjGN+nNK3dwwP8vOFueTmidR+SagKZD+m +S4qkyvfmEe10PGyEtws8WROdwyMRUA4FOgcNsoNKmW57ImbjwQs+L1ma7I27tawH +xNZUQCRRKHF14cAtWljUP4yNcr5nlqnr+2mmP5+bABEBAAGJBFsEGAEIACYCGwIW +IQTrTBv9TwQvbd3M7JF3IfY704tHlgUCXTX8rgUJBaOagAIpwV0gBBkBCAAGBQJd +NfyuAAoJEHi9ZUc8s70TzUAP/1Qq69M1CMd302TMnp1Yh1O06wkCPFGnMFMVwYRX +H5ggoYUb3IoCOmIAHOEn6v9fho0rYImS+oRDFeE08dOxeI+Co0xVisVHJ1JJvdnu +216BaXEsztZ0KGyUlFidXROrwndlpE3qlz4t1wh/EEaUH2TaQjRJ+O1mXJtF6vLB +1+YvMTMz3+/3aeX/elDz9aatHSpjBVS2NzbHurb9g7mqD45nB80yTBsPYT7439O9 +m70OqsxjoDqe0bL/XlIXsM9w3ei/Us7rSfSY5zgIKf7/iu+aJcMAQC9Zir7XASUV +sbBZywfpo2v4/ACWCHJ63lFST2Qrlf4Rjj1PhF0ifvB2XMR6SewNkDgVlQV+YRPO +1XwTOmloFU8qepkt8nm0QM1lhdOQdKVe0QyNn6btyUCKI7p4pKc8/yfZm5j6EboX +iGAb3XCcSFhR6pFrad12YMcKBhFYvLCaCN6g1q5sSDxvxqfRETvEFVwqOzlfiUH9 +KVY3WJcOZ3Cpbeu3QCpPkTiVZgbnR+WU9JSGQFEi7iZTrT8tct4hIg1Pa35B1lGZ +IlpYmzvdN5YoV9ohJoa1Bxj7qialTT/Su1Eb/toOOkOlqQ7B+1NBXzv9FmiBntC4 +afykHIeEIESNX9LdmvB+kQMW7d1d7Bs0aW2okPDt02vgwH2VEtQTtfq5B98jbwNW +9mbXCRB3IfY704tHliw+EAC5FNOwkABxZZ1C8K4wUDl2Oe7mewVRhVNqvTWS4uib +vFax78HDyLNqKmfi+yRHSQsDAkKr9GzmBc1DOabp4V+IRwj0vADHbcpwoGM7EJ2G +o/0RtdZiTP98B8DMACu17NwjM1l5EUExqjGEeXp3jEZGMSE8vqjq8djkvl8s5mUM +j09Wpj3Gl464NNQ/gnB0P/2sp11T0BVb2u32zNLJKh0ZP9QxXT3z93UBOeiT9BzR +hqFMyl04xpt5rqYDUdiL7y+tZDR28INZZ7aYsCs4NkA22Fh6nI3v43Us38+Kroru +09ipLE8A5fx3G5LxMwtWJA+zZisrrky86JYEFOULGpFuKrklP2bRyaHePjMeqOzD +Y5/n5unqk4+EZAPWIM4LFOwDtTD1BWmuDdpP/RjPuPZUhoMSW0p/Vv/FuBAnpgVQ +9D/kXI3xaAxKgaPp+AzQN50dCosmn643zAGrZTiIDIp1VtXVRFAVinN/mbJkqQJv +8zM/x0bc6EUNb/K8BP/JJp+x5D13DjtXYUEG8TFHz6YKZe9QzlhK5rZY/Fttwqvy +KvIKanXEjOf5/azkdOGlSN6Z74G4l22tui3y3CM+vmRrlMiBbLkCTuPfw8rS6uzi +B5No8PYBwovbqNvpm+dGNHySFTvNyJhzWmvCVt8FZ+c4tqOmwd/D+fhon0Pg42bu ++bkCDQRheAyfARAApNhsGrvrP6Spjk5xizJwd8m0LIlRi0YbMNkqkk70sgbYQMlt +VAKnUajQPPxXTJb1bqaRvPrwi1z5qT+twvvTNrckHjkdmlUKfrtRCMDeJT7uMK4e +r3bYEkYpvLsQXSyBxtes9McVYRNqzPzrf4LnH5KaBMNvPVWke7D5iMX1U5tUHKgh +ohUJd62Z5mugc/FDlyaBPMDviyuVpHHZhc+vmdwS0m+SC/ZYbAKxU6DauXTdkkk2 +wk3R0c60bqAnXn2B3caCwjOJCX4IEUYFoSqBCa6PmYqREqtU+ch1f4gCcvtw7gvC +22C77I7fVWWAEcPMSBm/dFY904VrjKFa/yFZik+36AuVoXtD0yP29n6zWlgscQuH +EVcTLrIgV+upnJUODL88I+dBtVisoFC2HLz0PNU4NKb4EyqoMcC/ZbjfTIg1bZJ/ +QmcezRZbM1a/onO51SYwDZyXmxRwhGXyW0KOLiMCn2G4aKVJAmuNYl6XrG1cwCqj +cHj4MjUwDBcmJ4wFBPBVVJse2SVW9eYhGzLN/ICSif1m/MLSUX5QH5IaxM4dTP+N +1lAFN0Xz5l06xnsgwmCkx4l054++PLh+lONLAfavqnhIWXU49Crn44LVmhVrGU5F +a7RjmiOsX1+qcv5N4Y1N3rPu3XRJcYTwXKjRN6ZD0am/cM/nsUnTO4YlMzcAEQEA +AYkEWwQYAQgAJgIbAhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJheAyfBQkFo5qA +AinBXSAEGQEIAAYFAmF4DJ8ACgkQTrJ9sqO4i4uCCQ//Ug1HJFOguZjWaz0NNYxD +SXBsEvwnfG7+d4og4pUY53D3NxaUa6BSg62FJtPxuO+7JsfVWPHjAUz5ye4xV+MP +nxe7pmmAIc3XBdgy7NjB4EUpoyDihLBMq4AkEnYiF8Sb9wCvJW8pjbNj67LOCLPH +e8CDeyOQA8NytIIk/aeS4dwnefNRso0COZ0yydYOuqplXA/32e7IyTxsC255nRIq +8ikK/bAh5g7vOSPrW+5A4U4aGX3w4G6LnBSG2BDD/96xNZiIY0pKYPd16t3YkdUD +TW0GYJZXgowsNuDcJwwxDXHdXWZ7oQbeCLAEvUj3FOwFRsRrp4Q31TTN0q+gxtKi +A43nAK7EDM78JcYyt4m0FS6kcRzr2hO7B7jboiGLcBtGs8CDe2cYYUK3XUehAU2d +E9Zve6cXxSUDatLK2/AXJCLenMFi3lWxMgDs0Qca4mz786ivoA4ifOG3VynsB+YM +Z8bLY3mjD7gYjoU97ZSoiDb6cWIav2FFk69dGAtAvx2UOcUKHKaV3Gb8n9QV0kZJ +ZGV0QOw+vMdARIq+xX0SOclBHmnnORArqPHTOpKUOCI0bYZPf8JK/Ah0KKHoKX0d +OEe1g2bdlg3RtT1baN6guHcAg01NyunS0Adm5AsXG6RuPno7l4H6d+Trv9faI2KL +jpl0lA3BtP1g3oKy1DP4KeoJEHch9jvTi0eWxrwP/0zlWCYOsNH5Id4SZsPKe8im +evCbj3lvboTYPc4u6HvbbwbYqLerzP2ajWSCdUAK4CMrAuvFildo4k6COh6VaZdi +DOwsKoJfs6Vd5oud5a+jRnv8+oktRBf5OAVc3RLfBG1RC9qI891JTOjGrTU7dBJr +RjRWdy9YQd/epN2I0RVtUaJlxKELoFj57FPERZgg+yomiheBARK+fLYY/oFTwJK3 ++Kt3rdnBtUeVpEiL6VjU6bqvIpUG+P0u27AspcacgDewg59+thcbY4tnsdo6DSZB +Q92bBPVGzpXPEhpQ/vZM63CG8qsZfQ1jw82ovmSnkKPLnBQRabFYVl0DCl1uYHg2 +4Up66w6Lj/tT2XbCeBf2n54K9HoUMV9f7/pLoTa0dE3UYI1K4GLZdp+yxMveUEjG +nh0YOTBmoBtpdy6Udejujil6xbH2gLwbICFm+boKVWwzrYCyfl51ASiq5dmqQwd3 +tPAg9Hc6qtvZ8cswyWyNOQpZo0myvfPaKrHWa9u2GqQmeGBwhckXJxFM/zau0yx6 +NMkSFI49kTglw0A77rcmlJUAQQeoXmTKMl6NM/3AUfvL8Qfu9/74kgoFI9pmQFky +BtcQMCeB2/JQ9K9ywPhi/gIebjftfMgKQsTW+/6Nl1yZ8q38y2n1J4p/acVlFc2K +PhbmKL4CvcSdlQS4CbvFuQINBGPs+VgBEADKbgLL+vAabKV2rGSDgY+IttTAtg9w +9Uor1+Q/CIWGxi/JQy7l7XTKjmS0wvdwU+9f/eGsjxigbvAcSsV1szyKfVQQFT2m +9KhDrBqNCAvQ5Tg6ZQdNe51oHwjiIQ1i7z8QoT22VucdTYqcMLAHe+g0aNqLLSSW +LAiW4z+nerclinjiTRCw/aWZJR1ozQd2eKwAw6rk19bHcihXo2E0K1EDmdHcNA8y +typxwWWXBftCYRWXi5J02GeZazxmx/DULnFgy2J4G0ULTqGWsbf/tCt22jqgyX+v +Fj/sJPn+l3IJqpyNY5yBG6GcejeP9vRoQrapGqHkcx+37f2vjwmpj5548JI52KEC +1yZeFwp8HjGLp+zGajpnokrKd4XJHniW9+bPLq7Yp7PNn65MaYvZUjv5enKd45fF +K6vJ3Ys/fx6PBXKKBs9flRIgdXOKSvtV+bGIG0I/p/JEZ/wPxRgxHPDK5jbcI6KB +Vm3Uk+CHFC4IBAtzdSh6H4Zfw1EH3dQZMLVBB/Sj34UQhlwAOlAXtZH3vks/Kpcl +WK8gnqz3i8HN0ezvcnQlRiRO8IqlN9/PmFqZeNTerklT7Tt0jXqiopLHL0FXR2Ls +ndeORfxDE1rhVOUxloeuIsY8x6gO8h2bGg41YapROjYxZZEcakg9Nch4XAlxeqB4 +ISttfbiVxeL2DQARAQABiQRbBBgBCAAmAhsCFiEE60wb/U8EL23dzOyRdyH2O9OL +R5YFAmPs+VgFCQWjmoACKcFdIAQZAQgABgUCY+z5WAAKCRDoiXn7mzCs8kblD/48 +yE3Wpi6Cw8RBzq2uzLdkuqXh691zG6VhHUZQNb85ewGjGDu/D25u2JFrhAcmlzOr +xggvL4a8WatPXQaPqDZaSh41elM1Ya0C7cNQq7xNVA0pcN5bQ+KXXZMuQaA89BCl +TSXITz6j4O4pvhAG8y8Q2E9Mv7UYas0OhDgzVIry2s1o2Pml1qjlb9jctO9crRUi +F6v9Ru9aQkgGHYt4uyP3HzKDfoNuzX/WX3O0Fm8NNpnJk6qZsLKwg7ukUdJOIEIb +LLNLU9ZYmys3wNtDKMfm4T79abSNwNIn4dd5hapH9BAuDJnk4WnFOap9AQZPgJX2 +WXKC2DXQZeSX1VXpI3rr7FSbSec8d5bitw7s20XWyQB2+ZoetRxNgR104GIh/Laj +tatLKFc9NnP9Smhey8nrxVZFx6HuXsnGOPkbjsiFYMsxtPVYnO72nBDTDP4ZejLO +aay2KtCb8pJkCH8U0guquDGVd+S02Xx947evyvHqGt5V0yVFPD7uAu7A5QBYXvtc +tzq93S1jZDIoMP93Oe8VpUrXBBfizzHVxP6VUmxM97IE+gjVRqN9PuMrp2D9yEBU +Gk44fQW5zyuuomYac7Mpx2fnWgGA/Al9ug2uvS4oIzUyLEJxpc6M8RYluacSIjFg +CigucRsvTBy6lobG1FMvnQyze6+fAeKbbrK85OuA1AkQdyH2O9OLR5bPGRAAmgSi +hpu4US/JoWnR/aeiFf9upobXVDnBnqOAXiMUaFeS+hUuh5EWUhDLIWYvXXhPacvb +pUOlxwLsLIdPRQGGSp1/rqhVRnmWsJ34DoAKxG7Elq8EArK/pF+v4wSUMegjAPJQ +evIcLvm83z+jHmbk1AEeioBYTq45RbzlHmyLmGK/zT13KnBUWE3sFkECoco+vMli +8oPeL+JMfiMgPb2vDs+58YlHq5W26pe08BwGzY5LQM7Jt52oxsqgXEX/N95QqgSc +sc625wCIE8/Qo5pXT0TKk+5ViFojs2Ei3mgXHBXFgISdAtWBEmqN9TESqPPrHzfn +Fk9t6mPg1r5Nt37IKO7oTzu7/SXrJlXPIQ99Nlq6HO/mMVdYjbWFBPw8+NGVGemQ +chOODZsksvHJGV4gjMpW1FC37MRNsiai1UMraVxzsrCte4/oqpa7bY8VdWw6p5mv +fdroLkwHW2cS2lgC8ft7e4npiHXXLAIib+sFHcrIkZu0uJxGCJOkUwkaDrAFKWzZ +YHc2YUrW5XN7CNBo/fe90r1W9/4esn59SM2mTMarrUn1fiExwFiUci4U+3/7U4Ii +ViNeNoZ2J1+hqxudlx1OT7Ae2Wg4dLASoEHaMKby4+JVVicA8jdlocrCbpEv1hVV +47hwiKc+VTQGvCZqs8eT+pbnw1Recd13J9Ny7bO5Ag0EZbladgEQAMSm1QPtyjAr +XdM1i2Y6439Jc/AJy3ykVjxTaDi6n5z7lgQipaQBSpWbwun4Op0W5fs1t8rYE2iP +A/KKoqVoEA3o3Hts71uNK+VttkGtUneYv6TvGsV1MYt4NJJOUQF6yPsVcrXMrtJb +0BXefjmWY4sBdMLXdVDcrRIRdv7r0XBevfX+Lng2BN8z/UtwlmEihHoy60ckJJgq +47pkfFho51+PjwEZJaPtEgRsXn2sgTMNHukGTrV8ub/aKWVNBPF0wYYF5LA2NHgV +p148nS11F4OgiNpCkAZmJQCPlyp4emYfxkihjh+TZKw6KcrxwOCx7YeceKK6wWvr +HHrwjJxl2nhatDIYNIlnVkqTlBp4A9gTdCxmciZ1xXb+QllLycBYMWgu2lo1Kk40 +NOfVljIKLatY88XwmJUySYLGyX5kePI29kc+yVGycYHsSgoOlyM/Vw+GXfuj/BRi +nKItjITxb6YM25wfhgctUer/NAao7dXprFMDUOz6C720dX/f7ISsiqmi7X1U588o +mNgLvJ/O8gPnyMtk1gWrwhFZDlVYI5AlYxx3MwoHntLZlvm8iEmR+X9LkhIwZcNd +vfafIpV+8LlOaIxt+uzNzcMsDHCGomUAf/GYXbI8/x1iHoopZIh99UZObfyxyz2S +SbVtUEBHXyKXHp0bFWM1Iz2LfQwxeNRRABEBAAGJBHIEGAEKACYWIQTrTBv9TwQv +bd3M7JF3IfY704tHlgUCZbladgIbAgUJBaOagAJACRB3IfY704tHlsF0IAQZAQoA +HRYhBA8G/4a+6vTnGGbuUjLuU1WmvG5CBQJluVp2AAoJEDLuU1WmvG5CmB4P/1Rn +XKHryp3UlaOAq/UAF2YKFS9NAggVwH8PhsFc6nZpruc+CFU1s5jwCuW9aiWgQ+Tj +BFvQ0h/bHLbujlTSmfyyyo/Ij+4vSxRzlmUa8lHPqyqv7fIsQ82AAs8WE/mV8Dif +24hsxJSZEH130DTkRqtnXS0FB6sOQPGj5EKAFt3v0vN/Z1QRX2eLmZc2jO7QfkdR +strvF3borb7xdt26/PM8g8RgYaG+fqIJ/NtGQF0XI+WUxuQ+mtRGEyVpL4qnwwno +kyxjsMxsJvvGIaPULKR1CahGJD4tAlyE3DvNikMRI2SDojaGyh5cw24mJJVZmx46 +7Q3tE4dwmAu8pCGCldUQBG6eprTL/WauyJcmkJr1qsSK7gyx+Uy8mwXESY/s5bwD +kzhlzaJ0WjBxqXfoHFIElHJfhLS0efqIr6NFmPUu4cBKJKoZoFBwTPTTEmWz7tE2 +mDgVO9Z6Q9fq7CwZS6J/GchieQgAy3Rxm5BizBZsWisY3BQ4JX1w6wH0Cae4rYCe +bkutFFWBg7JA3j2nkgfzsD3kYHYf5BllL2yV589dEocNjPios56vPi5kg9UQOFO1 +SaX4Efu1eArNcNteBxKf5pH8okDcgjqj9yXZRs6fI2Uk9zzz0UL63+iRSqSj8Kv6 +iepLCzOph1DHnY2tFghpSFYqlayhdprMJVk7GmLFoiYP/1nT6wq8k/RDS3/W7HEB +J8Rtxs1vL51nU0e5K7jgbUT9kaG2KBmlnRbgkELjvu0lX6zLFiyPcc5JkvE2AyfZ +7t5cIfanOS4hc0W9C66RQo2cvUxkn2gtCrM7KCTc16Iwe/uMC2RNEneNLiCetwc5 +DhpjYExR59szzQ9Npx31pefsmkSwKdutEz8W96l29yHYgIDoLYW3b6nuBRBfp4nA +XQ1gWqfEmFNFlKZBa2pPsKNlFgpchC+EiMQ/db1ElVNyW38K7IOx6hNGpEBJwbPu +HNef9WU3n2DIIgMBHTHPvbNHiCNTfuOM1+/BMbmK59RmW66TS0UaxZsswHHLZt7v +NN7SKzXsveT9+A1d6wZlVoy8Y3gykBKnBHGRaGO0zaXczHt4YsUA4L3is6lAjbIo +pU5M3j2F1RFKRr95+HZT/NXNeGbFvsdKmvP4ELtDAuYVMgYR8GqjI5yP/ccVMsi/ +mhT+cUxO/F7+7nixw1Go637Jqr/NF5kjjrBD8EiGy8QrGm6uBR3NGad0BnMWKa2Y +oYKF1m3Fs/evBkcymR+hSwFzkXm6WSOb8hzJIayFa6kAc7uSKyR5iG00p/neibbq +M1aUAQDBwV7g9wPmcdRIjJS2MtK1JXHZCR1gVKb+EObct6RJOVw8s58ES5O9wGZm +bVtIZ+JHTbuH+tg0EoRNcCbzuQINBGd9W+0BEADBFjNINSiiMRO6vCSu0G5SqJu/ +vjWJ/dhN7Lh791sas64UU/bWDQ0mqDms0D/oWjQNgapHRXAexuIynbStlSxXO0Qa +XEdq50BCVoKXj9Nwx63WWBXaR/cwAaBbKLYGUSsMEzqMXZul7VfuOyxGPcgHnz67 +dYDyUOIdUisFiBUkTwoUNXE4Qc9kA9i2jwBrY1s6+vtMX9J5uMUw78mtBG3U6TDr +7cgwlKe6nuNbt+EXpRsaKNPq5qC/9HEyRgq9i98Voo5b1gjC4adnYFZ70SKb6PrT +kkpf6b0wi4BNJxYzUBWzYdw9UKPwB4RM9zM20PSWxMuzBfn4sPN2FC0SjdZGeu92 +dZ4NcCwNJuPhFq4fz6TD6da2mEE9H0qlJIhgaNuTHyI3YXgLk4FH/+GhylO74uMh +cMa/A1nCq8Yr+4OscWxbyN6fv8Jsg2y1wQYdnIqsEH1vx99k5Xy/nF6rWqQfdy9c +UeCD00bzJyFSQQPieiP45asekajwAXph7nRby9rACbvdZUIy+RsRJoFTS+5flChr +MvofJoOEqJ58NzCNXNSq77yISZZE6aogqgp2hgQY2UFpLoslSUqvFSx6ti8ZViXf +Z7e9zKTi4I+/cpQ+RuzkBFYBgW7ysKnUWLyopPFE2GLu7E6JTRVTTL0KAiCca6KT +v8ZNe6itGuC7WmfKFQARAQABiQRyBBgBCgAmFiEE60wb/U8EL23dzOyRdyH2O9OL +R5YFAmd9W+0CGwIFCQWjmoACQAkQdyH2O9OLR5bBdCAEGQEKAB0WIQQOIlkXQUZw +9EQsJQ39UzwHwmRkjwUCZ31b7QAKCRD9UzwHwmRkj6YZD/4h1o52LhFwu7is7fs7 +7Ko5BpBpF1QKV4GRpvYdf7o5Wm9BSvvVQNSZVbs6sPUgWLsFMJBl9E1VQgnOSgMQ +2urGB9iIIHAvnTeGYwjIlKyZRBzVROn+xY4OfUk0nK/o1jnJCpz+adseMZh9JGV/ +65GfvdJX54j1L1bf4OWrp6BEA77TDmQZ9zqYMeMzlsaiuLxjLRdW4RVInjLYOQdx +OY5TXjcJpA2FdzBxrvqDGMtUxTANzkLkzs+XXg/OsRO94SvR0NwwaBEzyLs5WFz9 +KqELMFSgSOM+x40S5nwUGoFwl4/uuCxFGrpgGZVlld888WZwJOJMyb+dfrxEsWjJ +ui5eVRtfDC68792YuBM+ATK+zo2wJ8X3IK7CEw5cK8HgmAu0avX1sOVEspPd4dJD +SfAFU+ghtmufy7As7X1uI5IOyxQ1lpDCEqDf6wmkdrCX78tmoo2d98gFlJxKVmRu +vvPNdWABXZ/YNW57lix8fWe6vFY2pcyYVRXvX/DIcJNiu+uFVC+6ZzTWMZeCo9KE +wKlVRg2aDFhwnBO58ahm845/B/7p02NL7SuZPAT8rlLdA7XpfH7KY5Q5eaOVW3gU +KOnBQRM2Unea22r15rYsYS+whiqglmh2yejmE2vOVteJ3VJkSeaj3S3GGpHZdelI +/w6xbihzj67pYAG7PoZoJtav52HYD/91FDIGqsVOnn7IlotzN6c/Z07tJnCPJKSc +736L+1iDYyy7tvslUckW0vfOO92a+ikuPQRajlzUAZrWZe+23M+bIX4T8aCi3fGC +VWsr5wUK4wiBNQgAr5iQWRg2UjWNLxGuBvp+lk9w8BGp+qZWd/8TOrOHGmXz+N2W +ZBIrtTNbL0LYMxffBxcQIV+aC8jD8MfEetV9F7SsZo1Wza0wcEXyX/xUQ5pr+aks +aDtoNYKWwnJtlRqBgb6A8LPeRrzxTZVlHrOMUDHJSKNNSbspyRi8jmhJtfU17uE9 ++rpQkzv29ZRiDi4vtub6RSpcAaw+squMq7fNberxr7SNaWa7dVnJu4XHvAhS6838 +6Ng9vMhzyLE9GLyuwJ8FCv0jCiFdRFDayyEYZ0zAZz/gWjhdB8XAGJ5US0sEnD8d +qQE4JR5iLzXEZArHyGUDl45/JbxV7O5Z5D+SlBef/nHLCY/JBHc3LGGnM0Ht8GNj +d+om6kTznz3lZjxQCj0LFHYMeO3ADyk5uj8SKe9yMXHhl25Dlye1tZalTyosEIdP +UZMFqTLSQNh0nW5iJ8QYhO9bSaksUKadhHzVzoFk067OOpZLlt/SO3a9DTgBqJnm +jZzrnsTJpU2ctkX++wX6M0WSGfkQGJWbuf1tRHdl+IkfIu+kBE+iAhZoMQAysweF +p6XgWgagK7kCDQRpsHinARAAtf8XGrdD7k8bRRhCCjjJUGkGZdzSZLyQRQtQDGNP +ofM0LQ9xb03qMXN+qCPgQtNe3FwESEkonjICP+E9en32IYo9QoV9662h91MsQYpi +vlm2G/Ink2BxTJpmKwFZQwcoZ4Eq1wP5KWn2VL1qpWnyf/82/lPqEnc/xXHtks5o +YwNiRf5B/VPz+/IzzYayIxRmxaWtBVT6MAeDkEcZiZCGIXewaV2jC745ST0MsOLt +78pXFHuV3PlnaU+JzQO9gJFIgoyrXAKKkYAqtYuXUQfIZpsioor/WMrPnJ5v2miz +ygFHYzxh4ZVqOyeQu30TNlToJ/0As4cXEdBcMsdo4ZWqLRpavoN8k5wxNHiq5Xo7 +gyVvT4x2pQ4Cdc40NMS9fwx/re9aUMK+MkYX0n2nlfgMiyZUaswS0hwVXCWBwqT9 +1qzUh6JStncd6voLsAoKjpnDFelnDTUUOXqV2/CfLeeZSgdOF5jejJcqIzFd1mbN +Ui7QR+/2EBRjTvCruzA6M73SJGcnFciDVO70Z8+bTIqZNObmy2ARm6flKMsgbIN4 +e7QROdPXrEGKxRsLCEMbimGG5DYXNZPxDkt5TpTi61topkkmxKhRIAnUA1nhw+5P +aHvGxGwbqjEeRDQJLiAqE3BHh0hDCLqJbTnWqww4zSju/r8ICIOBT7W4sqBH0zVf +qscAEQEAAYkEcgQYAQoAJhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJpsHinAhsC +BQkFo5qAAkAJEHch9jvTi0eWwXQgBBkBCgAdFiEEuNvpzK8hFvhKCEvWHQnAFQBv +6rgFAmmweKcACgkQHQnAFQBv6rh54BAAo9VvH6LxBwbzUg1HQSIg/YMel80nMQzA +I3jfIPRTSC5CHcH0zfZpx6tLjU0eBD8E17jjp7NBE/cMDOGh4ocyyZTvG+rN9jtz +jk5Hd+4U+jxXF1VcYhYvKDNK2Y0BnLhcy+krXuOudP+r6CQqCMrMd70s2appU2w3 +p+p5wsCTSZV7WvxHHe6tSRUgzQz7e5CapwV0j/SQQYNJuX9konLGT6gs1Due54+U +xlBZ6BtfdTgMC7Ln7a7xntGG533oDd8J+LM+26O+Mzu/tFEZekwQqlewjT2I6N9N +0x/5u7cNMonWjiUMZZkEuts2ugjzktRviRvbDvhdIyje6+4uHicTF7pBUuLcRw8t +6onHrsjddE3I+rWw6jkm+5R5gLiriApKSzpRnSdA94GN3OCpmWjkO/XJTrmKT2/O +j6rrCyxnrfs+AQgfoev7f0B3F3UnRDQfYO3WhMYzgZ4CjVSpGyevsq5cAPYXkvyl +RH15wdJ43EToUwYheg0fvwexH41gkjbA+f1+XK1Ll5guspnUhlMTXni+pFTTFlhj +WF7lVnjcG8Ye66ymwIlMucShFssWlfCgFWh8lJx0ZYjNLrcYm1qGPH3w4c4RUH5E +YmXeb5zsREvRMaqEYTeDIWI4xvg/KsI66olxYn9fcwzuQrCmdVrzTn9LJw8C4d6U +LsuXrfChv0Cc/g//cIc2n6IuudMs7PI2f4YX0aN9HHVc/wDgS13sfJJWuXFwIttU +upMiKeiQ7083UKL84/1KhvEVFKQHpYeHS5+LpXH31F+JIVt0lJjhRuU1I5PcRE9W +uqacfqMlavkmz7q8WF6CpuGQGcHI4nSRfJYcMWHVt8swVPAiiITU+ou2mO2K31ao +p411RcZ/vFrC5BpPSKJpsD8Gvm80iVwZBeRXrzJW6B/83tnHNPsM0fGVojxDgE7i +Wp+Dv89n8BsQ5jIN8evHHe2I/T6Jd5zik7nfJbkzPCDgRPIQn6JesfpOyn6rUXYK +07+1t/yLHtMmyZTJBBFLqoJYOE2u6JoDuzCRYlZfj9Gm/uvVts9WcwMs4ymo5ttU +2+LXnOwKAVWizRmLLpywk348XAd1dEkQ5Tv4iTSKlyIQpRxKq50mFK31W1CjQgGe +M1Ctf3LXScrlVYldo5Wn0PmEfEVDB2E9j94jGsB/dBRYWAMZZe1eXX7oAdhQIedW +xDYjKzy/ZNTFLqIgwAawvxaKOLqm8pCVCa/Hkd8x7PeL/CD4q+XEuhRanIZasbaP +wOSz6cWG1532PsdUEJMr93rjh9vvcZ2Aee4BEH9ly+D/qWUJysuljMlpxQ+mG9n0 +EFRbD9Lhk5tL9ArJlsUZ3Wg/a2N+cNFSkXzUmw0Rj/iUmZcSITcM8QOSK6U= +=CkA1 +-----END PGP PUBLIC KEY BLOCK----- From 949665ac1282a5730effc40151138910b930e881 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 22 Jun 2026 00:35:38 -0700 Subject: [PATCH 05/11] Add FST (Fast Signal Trace) binary waveform writer Adds FST format support as an alternative output format for WaveDumper: - lib/src/fst/fst_types.dart: FST type definitions (FstVarType, FstVarDirection, FstScopeType, FstFileType, FstSignalHandle, FstWriterConfig) - lib/src/fst/fst_writer.dart: GTKWave-compatible binary FST writer - lib/src/wave_dumper.dart: adds WaveFormat enum (vcd/fst) and FST format support - lib/rohd.dart: exports fst_types.dart and fst_writer.dart - test/fst_writer_test.dart: FST writer tests No DevTools integration on this branch; that is added later. --- lib/rohd.dart | 2 + lib/src/fst/fst_types.dart | 236 ++++++++ lib/src/fst/fst_writer.dart | 1020 +++++++++++++++++++++++++++++++++++ lib/src/wave_dumper.dart | 214 +++++++- test/fst_writer_test.dart | 419 ++++++++++++++ 5 files changed, 1862 insertions(+), 29 deletions(-) create mode 100644 lib/src/fst/fst_types.dart create mode 100644 lib/src/fst/fst_writer.dart create mode 100644 test/fst_writer_test.dart diff --git a/lib/rohd.dart b/lib/rohd.dart index 841505590..b9b3b3929 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -4,6 +4,8 @@ export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; +export 'src/fst/fst_types.dart'; +export 'src/fst/fst_writer.dart'; export 'src/interfaces/interfaces.dart'; export 'src/module.dart'; export 'src/modules/modules.dart'; diff --git a/lib/src/fst/fst_types.dart b/lib/src/fst/fst_types.dart new file mode 100644 index 000000000..b39b9ef5c --- /dev/null +++ b/lib/src/fst/fst_types.dart @@ -0,0 +1,236 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_types.dart +// Enumerations and constants for the FST (Fast Signal Trace) binary format. +// +// 2026 February +// Author: Desmond Kirkpatrick + +/// FST block types (from fstapi.h). +enum FstBlockType { + /// File header. + header(0), + + /// Value change data (zlib compressed). + vcData(1), + + /// Blackout regions. + blackout(2), + + /// Geometry (per-variable back-pointers for random access). + geometry(3), + + /// Hierarchy (zlib compressed). + hierarchy(4), + + /// Value changes with dynamic aliases (zlib). + vcDataDynamicAlias(5), + + /// Hierarchy (LZ4 compressed). + hierarchyLz4(6), + + /// Hierarchy (LZ4 double compressed). + hierarchyLz4Duo(7), + + /// Value changes with dynamic aliases v2 (modern recommended format). + vcDataDynamicAlias2(8), + + /// GZip wrapper. + gzipWrapper(254), + + /// Skip/padding. + skip(255); + + const FstBlockType(this.value); + + /// The numeric value of this block type as written in FST files. + final int value; +} + +/// FST scope types. +enum FstScopeType { + /// A Verilog/SystemVerilog module instantiation scope. + module(0), + + /// A Verilog/SystemVerilog task scope. + task(1), + + /// A Verilog/SystemVerilog function scope. + function_(2), + + /// A named `begin`..`end` block scope (Verilog). + begin(3), + + /// A named `fork`..`join` block scope (Verilog). + fork(4), + + /// A `generate` block scope (SystemVerilog). + generate(5), + + /// A `struct` type scope (SystemVerilog). + struct_(6), + + /// A `union` type scope (SystemVerilog). + union(7), + + /// A `class` scope (SystemVerilog). + class_(8), + + /// An `interface` scope (SystemVerilog). + interface(9), + + /// A `package` scope (SystemVerilog). + package(10), + + /// A `program` scope (SystemVerilog). + program(11); + + const FstScopeType(this.value); + + /// The numeric value of this scope type as written in FST files. + final int value; +} + +/// FST variable types. +enum FstVarType { + /// An event variable. + event(0), + + /// A Verilog `integer` variable (32-bit, 4-state). + integer(1), + + /// A Verilog `parameter` or `localparam`. + parameter(2), + + /// A `real` variable (double-precision floating point). + real(3), + + /// A `real` parameter. + realParameter(4), + + /// A `reg` variable (Verilog 4-state storage). + reg(5), + + /// A `supply0` net (logic-0 power supply). + supply0(6), + + /// A `supply1` net (logic-1 power supply). + supply1(7), + + /// A `time` variable. + time(8), + + /// A `tri` net (tri-state, same resolution as `wire`). + tri(9), + + /// A `triand` net (tri-state with wired-AND resolution). + triAnd(10), + + /// A `trior` net (tri-state with wired-OR resolution). + triOr(11), + + /// A `trireg` net (retains last driven value when undriven). + triReg(12), + + /// A `tri0` net (pulls to 0 when undriven). + tri0(13), + + /// A `tri1` net (pulls to 1 when undriven). + tri1(14), + + /// A `wand` net (wired-AND). + wand(15), + + /// A `wire` net (standard Verilog interconnect). + wire(16), + + /// A `wor` net (wired-OR). + wor(17), + + /// A port variable. + port(18), + + /// A sparse array variable. + sparseArray(19), + + /// A `realtime` variable. + realTime(20), + + /// A generic string variable. + genericString(21), + + // SystemVerilog types + + /// A SystemVerilog `bit` type (2-state, unsigned). + bit(22), + + /// A SystemVerilog `logic` type (4-state). + logic(23), + + /// A SystemVerilog `int` type (32-bit, 2-state, signed). + int_(24), + + /// A SystemVerilog `shortint` type (16-bit, 2-state, signed). + shortInt(25), + + /// A SystemVerilog `longint` type (64-bit, 2-state, signed). + longInt(26), + + /// A SystemVerilog `byte` type (8-bit, 2-state, signed). + byte_(27), + + /// A SystemVerilog `enum` type. + enum_(28), + + /// A SystemVerilog `shortreal` type (single-precision float). + shortReal(29); + + const FstVarType(this.value); + + /// The numeric value of this variable type as written in FST files. + final int value; +} + +/// FST variable direction. +enum FstVarDirection { + /// No direction specified (implicit net). + implicit(0), + + /// Input port. + input(1), + + /// Output port. + output(2), + + /// Bidirectional (inout) port. + inout(3), + + /// Buffer port (output that can be read back). + buffer(4), + + /// Linkage port (VHDL linkage mode). + linkage(5); + + const FstVarDirection(this.value); + + /// The numeric value of this direction as written in FST files. + final int value; +} + +/// FST file type. +enum FstFileType { + /// Verilog source. + verilog(0), + + /// VHDL source. + vhdl(1), + + /// Mixed Verilog and VHDL source. + verilogVhdl(2); + + const FstFileType(this.value); + + /// The numeric value of this file type as written in FST files. + final int value; +} diff --git a/lib/src/fst/fst_writer.dart b/lib/src/fst/fst_writer.dart new file mode 100644 index 000000000..8f31500fa --- /dev/null +++ b/lib/src/fst/fst_writer.dart @@ -0,0 +1,1020 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_writer.dart +// Pure Dart implementation of FST (Fast Signal Trace) binary writer. +// +// Writes valid FST files compatible with GTKWave, Surfer, wellen reader. +// Reference: fst-reader 0.14.2 (io.rs, types.rs) and fstapi.c from GTKWave. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:rohd/rohd.dart'; + +/// Configuration for the FST writer. +class FstWriterConfig { + /// Timescale exponent. The timescale is 10^exponent seconds. + /// Default: -12 (picoseconds). + final int timescaleExponent; + + /// Zlib compression level (0-9). Higher = smaller but slower. + /// Default: 4. + final int compressionLevel; + + /// Writer version string embedded in the file header. + final String version; + + /// File type: Verilog, VHDL, or combined. + final FstFileType fileType; + + /// Maximum number of value changes to buffer before auto-flushing + /// a VcData block to disk. Set to 0 (default) to disable auto-flush + /// and write a single block at [FstWriter.finish]. + /// + /// When non-zero, [FstWriter.emitValueChange] automatically calls + /// [FstWriter.flushBlock] once the buffer reaches this threshold. + /// This bounds memory usage and makes historical data available on + /// disk for read-back. + final int maxChangesPerBlock; + + /// Creates configuration for the FST writer. + const FstWriterConfig({ + this.timescaleExponent = -12, + this.compressionLevel = 4, + this.version = 'ROHD FST Writer', + this.fileType = FstFileType.verilog, + this.maxChangesPerBlock = 0, + }); +} + +/// A handle to a declared signal in the FST file. +/// +/// Handles are 1-based (matching VST convention). Index 0 is unused. +class FstSignalHandle { + /// The 1-based handle value. + final int handle; + + /// Creates a signal handle from a 1-based handle value. + const FstSignalHandle(this.handle); +} + +/// Metadata about a flushed VcData block in the FST file. +/// +/// Each entry in [FstWriter.blockIndex] represents a block that has been +/// written to disk and can be read back independently for on-demand +/// signal queries without loading the entire file into memory. +class FstBlockIndex { + /// File offset of the block_type byte in the FST file. + final int fileOffset; + + /// Section length (the section_length field from the block header). The full + /// block occupies bytes [fileOffset .. fileOffset + 1 + sectionLength). + final int sectionLength; + + /// First timestamp in this block. + final int startTime; + + /// Last timestamp in this block. + final int endTime; + + /// Creates a block index entry. + const FstBlockIndex({ + required this.fileOffset, + required this.sectionLength, + required this.startTime, + required this.endTime, + }); +} + +/// Public metadata about a declared signal in the FST writer. +class FstSignalInfo { + /// Signal name. + final String name; + + /// Bit width (number of bits for digital signals, 8 for real). + final int width; + + /// Whether this is a real-valued (f64) signal. + final bool isReal; + + /// Creates signal info. + const FstSignalInfo({ + required this.name, + required this.width, + required this.isReal, + }); +} + +/// Internal: information about a declared signal. +class _SignalDecl { + final String name; + final int width; + final FstVarType varType; + final FstVarDirection direction; + final bool isReal; + + _SignalDecl({ + required this.name, + required this.width, + required this.varType, + required this.direction, + this.isReal = false, + }); + + /// The geometry file_format value for this signal. + int get geometryValue { + if (isReal) { + return 0; + } + return width; // 1 for 1-bit, N for N-bit + } + + /// The number of bytes this signal occupies in the frame section. + int get frameLength { + if (isReal) { + return 8; + } + return width; // 1 byte per bit for character-encoded values + } +} + +/// Internal: a buffered value change. +class _ValueChange { + final int time; + final int handleIndex; // 0-based + final String value; + + _ValueChange(this.time, this.handleIndex, this.value); +} + +/// Internal: an entry in the hierarchy being built. +sealed class _HierarchyEntry {} + +class _ScopeEntry extends _HierarchyEntry { + final FstScopeType type; + final String name; + final String component; + _ScopeEntry(this.type, this.name, {this.component = ''}); +} + +class _UpScopeEntry extends _HierarchyEntry {} + +class _VarEntry extends _HierarchyEntry { + final FstVarType varType; + final FstVarDirection direction; + final String name; + final int width; + final int handle; // 1-based + _VarEntry(this.varType, this.direction, this.name, this.width, this.handle); +} + +/// Pure Dart writer for the FST (Fast Signal Trace) binary format. +/// +/// Usage: +/// ```dart +/// final writer = FstWriter('output.fst'); +/// writer.pushScope('top'); +/// final clk = writer.declareSignal('clk', 1); +/// final data = writer.declareSignal('data', 8); +/// writer.popScope(); +/// writer.writeHeader(); +/// +/// writer.emitValueChange(0, clk, '0'); +/// writer.emitValueChange(0, data, '00000000'); +/// writer.emitValueChange(5, clk, '1'); +/// writer.emitValueChange(10, clk, '0'); +/// +/// writer.finish(); +/// ``` +class FstWriter { + /// The output file path. + final String filePath; + + /// Writer configuration. + final FstWriterConfig config; + + /// All declared signals (0-indexed). + final List<_SignalDecl> _signals = []; + + /// Hierarchy entries in declaration order. + final List<_HierarchyEntry> _hierEntries = []; + + /// Scope counts for header. + int _scopeCount = 0; + + /// Variable counts for header (including aliases). + int _varCount = 0; + + /// Buffered value changes. + final List<_ValueChange> _changes = []; + + /// The start time of the simulation. + int _startTime = 0; + + /// The end time of the simulation. + int _endTime = 0; + + /// Whether the header has been written yet. + bool _headerWritten = false; + + /// The output file random access handle. + late final RandomAccessFile _file; + + /// Current value of each signal (tracks latest emitted value). + /// Initialized in [writeHeader]. + late List _currentValues; + + /// Base values for the next block's frame section. + /// Updated after each [flushBlock] call. + late List _nextFrameBase; + + /// Index of flushed VcData blocks for read-back. + final List _blockIndex = []; + + /// Number of VcData blocks written so far. + int _vcSectionCount = 0; + + /// Creates an FST writer that will write to [filePath]. + FstWriter(this.filePath, {this.config = const FstWriterConfig()}) { + final file = File(filePath)..createSync(recursive: true); + _file = file.openSync(mode: FileMode.write); + } + + /// Pushes a new scope onto the hierarchy. + void pushScope(String name, + {FstScopeType type = FstScopeType.module, String component = ''}) { + _hierEntries.add(_ScopeEntry(type, name, component: component)); + _scopeCount++; + } + + /// Pops the current scope. + void popScope() { + _hierEntries.add(_UpScopeEntry()); + } + + /// Declares a signal and returns its handle. + /// + /// [name] is the signal name. [width] is the bit width (1 for single bit). + /// Returns an [FstSignalHandle] used for emitting value changes. + FstSignalHandle declareSignal( + String name, + int width, { + FstVarType varType = FstVarType.wire, + FstVarDirection direction = FstVarDirection.implicit, + }) { + final handle = _signals.length + 1; // 1-based + final decl = _SignalDecl( + name: name, + width: width, + varType: varType, + direction: direction, + isReal: varType == FstVarType.real || varType == FstVarType.realParameter, + ); + _signals.add(decl); + _hierEntries.add(_VarEntry(varType, direction, name, width, handle)); + _varCount++; + return FstSignalHandle(handle); + } + + /// Writes the FST file header. + /// + /// Must be called after all signals are declared and before any value + /// changes. The header is initially written with placeholder values for + /// start_time and end_time, which are fixed up during [finish]. + void writeHeader() { + if (_headerWritten) { + throw StateError('Header already written'); + } + _writeHeaderBlock(); + _headerWritten = true; + + // Initialize value tracking for incremental block flushing + final defaults = List.generate(_signals.length, (i) { + final sig = _signals[i]; + return sig.isReal ? '0.0' : 'x' * sig.width; + }); + _currentValues = List.from(defaults); + _nextFrameBase = List.from(defaults); + } + + /// Records a value change for a signal at a given simulation time. + /// + /// [time] is the simulation timestamp. + /// [handle] is the signal handle returned by [declareSignal]. + /// [value] is the new value as a string (e.g., '0', '1', '01010101', 'x'). + void emitValueChange(int time, FstSignalHandle handle, String value) { + if (!_headerWritten) { + throw StateError('Must call writeHeader() before emitting value changes'); + } + if (_endTime < time) { + _endTime = time; + } + _changes.add(_ValueChange(time, handle.handle - 1, value)); + _currentValues[handle.handle - 1] = value; + + // Auto-flush if threshold is reached + if (config.maxChangesPerBlock > 0 && + _changes.length >= config.maxChangesPerBlock) { + flushBlock(); + } + } + + /// Finalizes the FST file: flushes remaining value changes, writes + /// geometry and hierarchy blocks, fixes up the header, and closes the file. + void finish() { + if (!_headerWritten) { + writeHeader(); + } + + // Flush any remaining buffered changes as a final VcData block + flushBlock(); + + _writeGeometryBlock(); + _writeHierarchyBlock(); + _fixupHeader(); + + _file.closeSync(); + } + + /// Releases resources. Call [finish] first for a valid file. + void dispose() { + try { + _file.closeSync(); + } on FileSystemException { + // already closed + } + } + + /// Flushes buffered value changes to disk as a VcData block. + /// + /// After flushing, the changes are cleared from memory and the block + /// is recorded in [blockIndex] for later read-back. This enables + /// incremental writing where only recent unflushed changes remain + /// in memory while historical data lives on disk. + /// + /// Does nothing if no changes are buffered. + void flushBlock() { + if (_changes.isEmpty) { + return; + } + if (!_headerWritten) { + throw StateError('Must call writeHeader() before flushing blocks'); + } + + // Sort changes by time, then by handle + _changes.sort((a, b) { + final cmp = a.time.compareTo(b.time); + return cmp != 0 ? cmp : a.handleIndex.compareTo(b.handleIndex); + }); + + final blockStart = _changes.first.time; + final blockEnd = _changes.last.time; + + // Build frame: carry-over state from previous block, overridden by + // any changes at this block's start time. + final frameValues = List.from(_nextFrameBase); + for (final c in _changes) { + if (c.time == blockStart) { + frameValues[c.handleIndex] = c.value; + } + } + + final blockOffset = _file.positionSync(); + _writeVcDataBlock( + blockStartTime: blockStart, + blockEndTime: blockEnd, + frameValues: frameValues, + ); + final blockEndPos = _file.positionSync(); + + // Record block in the index for read-back + _blockIndex.add(FstBlockIndex( + fileOffset: blockOffset, + sectionLength: blockEndPos - blockOffset - 1, + startTime: blockStart, + endTime: blockEnd, + )); + _vcSectionCount++; + + // Update global time range + if (_vcSectionCount == 1) { + _startTime = blockStart; + } + _endTime = blockEnd; + + // Carry-over state for next block's frame + _nextFrameBase = List.from(_currentValues); + _changes.clear(); + } + + // ─── Public query API for hybrid disk+memory access ─── + + /// Index of all flushed VcData blocks. + /// + /// Each entry contains the file offset and time range, enabling + /// the [FstBlockReader] to read specific blocks on demand. + List get blockIndex => List.unmodifiable(_blockIndex); + + /// Number of declared signals. + int get signalCount => _signals.length; + + /// Public metadata about each declared signal (indexed by handle-1). + List get signalInfoList => _signals + .map((s) => FstSignalInfo(name: s.name, width: s.width, isReal: s.isReal)) + .toList(); + + /// The output file handle for read-back by [FstBlockReader]. + /// + /// **Warning**: The caller must not close or modify the file position + /// without restoring it. The writer uses this same handle for writing. + RandomAccessFile get file => _file; + + /// Query unflushed value changes for a specific signal handle. + /// + /// Returns changes from the hot buffer for signal [handleIndex] (0-based) + /// within the time range \[startTime, endTime\]. + List<({int time, String value})> queryHotBuffer( + int handleIndex, + int startTime, + int endTime, + ) => + _changes + .where((c) => + c.handleIndex == handleIndex && + c.time >= startTime && + c.time <= endTime) + .map((c) => (time: c.time, value: c.value)) + .toList(); + + /// Returns the current (latest) value of signal [handleIndex] (0-based). + String getCurrentValue(int handleIndex) => _currentValues[handleIndex]; + + /// Returns the latest known values of all signals (read-only). + List get currentValues => List.unmodifiable(_currentValues); + + // ─────────────── Header Block ─────────────── + + static const int _headerLength = 329; + static const int _headerVersionMaxLen = 128; + static const int _headerDateMaxLen = 119; + + /// Writes the FST_BL_HDR block. + void _writeHeaderBlock() { + _file.writeByteSync(FstBlockType.header.value); + _writeU64(_headerLength); // section_length (fixed size) + _writeU64(_startTime); // start_time (placeholder) + _writeU64(_endTime); // end_time (placeholder) + _writeF64LE(math.e); // double endian test + _writeU64(0); // memory_used_by_writer + _writeU64(_scopeCount); // scope_count + _writeU64(_varCount); // var_count + _writeU64(_signals.length); // max_var_id_code + _writeU64(1); // vc_section_count (we write one block) + _file.writeByteSync(config.timescaleExponent & 0xFF); // timescale_exponent + _writeFixedString(config.version, _headerVersionMaxLen); + _writeFixedString(_dateString(), _headerDateMaxLen); + _file.writeByteSync(config.fileType.value); // file_type + _writeU64(0); // time_zero + } + + /// Fixes up the header with actual start/end times and block count. + void _fixupHeader() { + final savedPos = _file.positionSync(); + _file.setPositionSync(1 + 8); // skip block_type + section_length + _writeU64(_startTime); + _writeU64(_endTime); + // Fix vc_section_count with actual number of blocks written + // Layout: block_type(1) + section_length(8) + start_time(8) + + // end_time(8) + endian_test(8) + memory_used(8) + scope_count(8) + + // var_count(8) + max_var_id(8) = offset 65 + _file.setPositionSync( + 1 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8); // at vc_section_count + _writeU64(_vcSectionCount); + _file.setPositionSync(savedPos); + } + + // ─────────────── Hierarchy Block ─────────────── + + static const int _hierTypeScopeBegin = 254; + static const int _hierTypeUpScope = 255; + + /// Writes the FST_BL_HIER block (zlib/gzip compressed hierarchy). + void _writeHierarchyBlock() { + // Build uncompressed hierarchy bytes + final buf = BytesBuilder(copy: false); + var handleCount = 0; + + for (final entry in _hierEntries) { + switch (entry) { + case _ScopeEntry(): + buf.addByte(_hierTypeScopeBegin); + buf.addByte(entry.type.value); + buf.add(_cString(entry.name)); + buf.add(_cString(entry.component)); + case _UpScopeEntry(): + buf.addByte(_hierTypeUpScope); + case _VarEntry(): + buf.addByte(entry.varType.value); + buf.addByte(entry.direction.value); + buf.add(_cString(entry.name)); + buf.add(encodeVarint(entry.width)); // length + // alias = 0 means "new handle, not an alias" + buf.add(encodeVarint(0)); + handleCount++; + } + } + + final uncompressed = buf.toBytes(); + assert(handleCount == _signals.length, + 'Handle count mismatch: $handleCount vs ${_signals.length}'); + + // Write as FST_BL_HIER (type 4) with gzip compression + _file.writeByteSync(FstBlockType.hierarchy.value); + final sectionLengthPos = _file.positionSync(); + _writeU64(0); // placeholder section_length + _writeU64(uncompressed.length); // uncompressed_length + + // Write gzip header + deflate-compressed data + _writeGzipCompressed(uncompressed); + + // Fix section_length + final endPos = _file.positionSync(); + final sectionLength = endPos - sectionLengthPos; + _file.setPositionSync(sectionLengthPos); + _writeU64(sectionLength); + _file.setPositionSync(endPos); + } + + // ─────────────── Geometry Block ─────────────── + + /// Writes the FST_BL_GEOM block. + void _writeGeometryBlock() { + // Build uncompressed geometry: one varint per signal + final buf = BytesBuilder(copy: false); + for (final sig in _signals) { + buf.add(encodeVarint(sig.geometryValue)); + } + final uncompressed = buf.toBytes(); + final compressed = + _zlibCompress(uncompressed, config.compressionLevel, allowRaw: true); + + _file.writeByteSync(FstBlockType.geometry.value); + final sectionLength = 3 * 8 + compressed.length; + _writeU64(sectionLength); // section_length + _writeU64(uncompressed.length); // uncompressed_length + _writeU64(_signals.length); // max_handle + _file.writeFromSync(compressed); + } + + // ─────────────── VcData Block (DynamicAlias2) ─────────────── + + /// Writes a single FST_BL_VCDATA_DYN_ALIAS2 block from the current + /// `_changes` buffer. + /// + /// [blockStartTime] and [blockEndTime] are the time range for this block. + /// [frameValues] contains the initial value of each signal at the block's + /// start time (carry-over state plus changes at blockStartTime). + /// + /// Assumes `_changes` is already sorted by time, then by handle. + void _writeVcDataBlock({ + required int blockStartTime, + required int blockEndTime, + required List frameValues, + }) { + // Build sorted unique time table. + // Only include timestamps that have signal chain entries (i.e., after + // blockStartTime). Changes at blockStartTime go into the frame section. + // The fst-reader only reads the frame when time_table[0] > start_time; + // if blockStartTime were included, the frame would be skipped and all + // signals would appear as 'x'. + final timeSet = {}; + for (final c in _changes) { + if (c.time != blockStartTime) { + timeSet.add(c.time); + } + } + final timeTable = timeSet.toList()..sort(); + // Map timestamp → index + final timeToIndex = {}; + for (var i = 0; i < timeTable.length; i++) { + timeToIndex[timeTable[i]] = i; + } + + // Build per-signal value change chains + final signalData = _buildSignalData(timeToIndex, blockStartTime); + + // Pack each signal's data (store uncompressed with varint(0) prefix) + final packedSignals = []; + for (final data in signalData) { + if (data.isEmpty) { + packedSignals.add(Uint8List(0)); + } else { + final packed = BytesBuilder(copy: false) + ..add(encodeVarint(0)) // means "uncompressed" + ..add(data); + packedSignals.add(packed.toBytes()); + } + } + + // Build frame bytes + final frameBytes = _buildFrameBytes(frameValues); + final frameCompressed = + _zlibCompress(frameBytes, config.compressionLevel, allowRaw: true); + + // Build the signal offset chain (DynamicAlias2 format) + final chainBytes = _buildOffsetChain(packedSignals); + + // Build time table bytes + final timeTableBytes = _buildTimeTableBytes(timeTable); + + // Compute memory required for traversal + var memRequired = 0; + for (final ps in packedSignals) { + memRequired += ps.length; + } + + // Now assemble the VcData block + _file.writeByteSync(FstBlockType.vcDataDynamicAlias2.value); + final sectionLengthPos = _file.positionSync(); + _writeU64(0); // placeholder section_length + _writeU64(blockStartTime); // start_time + _writeU64(blockEndTime); // end_time + _writeU64(memRequired); // mem_required_for_traversal + + // Frame section + _file + ..writeFromSync(encodeVarint(frameBytes.length)) // unc len + ..writeFromSync(encodeVarint(frameCompressed.length)) // comp len + ..writeFromSync(encodeVarint(_signals.length)) // max_handle + ..writeFromSync(frameCompressed) + + // Value change section + ..writeFromSync(encodeVarint(_signals.length)) // max_handle + ..writeByteSync(0x5A); // pack_type = 'Z' (zlib) + + // Write per-signal packed data + packedSignals.forEach(_file.writeFromSync); + + // Write offset chain + _file.writeFromSync(chainBytes); + _writeU64(chainBytes.length); // chain_compressed_length + + // Write time table + _file.writeFromSync(timeTableBytes); + + // Fix section_length + final endPos = _file.positionSync(); + final sectionLength = endPos - sectionLengthPos; + _file.setPositionSync(sectionLengthPos); + _writeU64(sectionLength); + _file.setPositionSync(endPos); + } + + /// Builds frame bytes: the initial value of each signal concatenated. + Uint8List _buildFrameBytes(List initialValues) { + final buf = BytesBuilder(copy: false); + for (var i = 0; i < _signals.length; i++) { + final sig = _signals[i]; + if (sig.isReal) { + // Encode as f64 little-endian bytes + final d = double.tryParse(initialValues[i]) ?? 0.0; + final bd = ByteData(8)..setFloat64(0, d, Endian.little); + buf.add(bd.buffer.asUint8List()); + } else { + // Character-encoded value: one byte per bit + final val = initialValues[i]; + for (var j = 0; j < sig.width; j++) { + buf.addByte(j < val.length ? val.codeUnitAt(j) : 0x78); // 'x' + } + } + } + return buf.toBytes(); + } + + /// Builds per-signal value change encoded data. + /// + /// Returns a list of byte arrays, one per signal (0-indexed). + /// Each byte array contains the encoded value change chain for that signal. + /// Changes at [blockStartTime] are skipped (captured in the frame). + List _buildSignalData( + Map timeToIndex, int blockStartTime) { + // Group changes by signal handle index + final signalChanges = List>.generate( + _signals.length, + (_) => [], + ); + for (final c in _changes) { + // Skip changes at blockStartTime — those are captured in the frame + if (c.time == blockStartTime) { + continue; + } + signalChanges[c.handleIndex].add(c); + } + + final result = []; + for (var sigIdx = 0; sigIdx < _signals.length; sigIdx++) { + final changes = signalChanges[sigIdx]; + if (changes.isEmpty) { + result.add(Uint8List(0)); + continue; + } + + final sig = _signals[sigIdx]; + final buf = BytesBuilder(copy: false); + var prevTimeIndex = 0; + + for (final c in changes) { + final timeIndex = timeToIndex[c.time]!; + final timeDelta = timeIndex - prevTimeIndex; + prevTimeIndex = timeIndex; + + if (sig.frameLength == 1) { + // 1-bit signal: compact encoding + buf.add(_encodeOneBitChange(timeDelta, c.value)); + } else if (sig.isReal) { + // Real signal + buf.add(_encodeRealChange(timeDelta, c.value)); + } else { + // Multi-bit signal + buf.add(_encodeMultiBitChange(timeDelta, c.value, sig.width)); + } + } + result.add(buf.toBytes()); + } + return result; + } + + /// Encodes a 1-bit signal value change. + /// + /// Format: varint where: + /// - Normal (0/1): bit0=0, bit1=value, bits2+= time_index_delta + /// - Special (x/z/etc): bit0=1, bits1-3=rcv_index, bits4+=time_index_delta + Uint8List _encodeOneBitChange(int timeDelta, String value) { + // RCV_STR: [x, z, h, u, w, l, -, ?] + const rcvChars = 'xzhuwl-?'; + final ch = value.isNotEmpty ? value[value.length - 1] : 'x'; + + int vli; + if (ch == '0') { + vli = (timeDelta << 2) | (0 << 1) | 0; // bit0=0, bit1=0 + } else if (ch == '1') { + vli = (timeDelta << 2) | (1 << 1) | 0; // bit0=0, bit1=1 + } else { + final rcvIdx = rcvChars.indexOf(ch); + final idx = rcvIdx >= 0 ? rcvIdx : 0; // default to 'x' + vli = (timeDelta << 4) | (idx << 1) | 1; // bit0=1, bits1-3=idx + } + return encodeVarint(vli); + } + + /// Encodes a multi-bit signal value change. + /// + /// Format: varint(time_delta << 1 | encoding_bit) then value bytes. + /// encoding_bit=0: 2-state packed bits; encoding_bit=1: 4-state characters. + Uint8List _encodeMultiBitChange(int timeDelta, String value, int width) { + final buf = BytesBuilder(copy: false); + + // Check if value contains only 0/1 (2-state) + final is2State = value.runes.every((c) => c == 0x30 || c == 0x31); + + if (is2State) { + // 2-state: pack bits into bytes, MSB first + buf.add(encodeVarint((timeDelta << 1) | 0)); + final byteCount = (width + 7) ~/ 8; + final bytes = Uint8List(byteCount); + for (var i = 0; i < width; i++) { + if (i < value.length && value[i] == '1') { + final byteIdx = i ~/ 8; + final bitIdx = 7 - (i % 8); + bytes[byteIdx] |= 1 << bitIdx; + } + } + buf.add(bytes); + } else { + // 4-state: raw character bytes + buf.add(encodeVarint((timeDelta << 1) | 1)); + for (var i = 0; i < width; i++) { + buf.addByte(i < value.length ? value.codeUnitAt(i) : 0x78); + } + } + return buf.toBytes(); + } + + /// Encodes a real signal value change. + Uint8List _encodeRealChange(int timeDelta, String value) { + final buf = BytesBuilder(copy: false) + ..add(encodeVarint((timeDelta << 1) | 1)); + final d = double.tryParse(value) ?? 0.0; + final bd = ByteData(8)..setFloat64(0, d, Endian.little); + buf.add(bd.buffer.asUint8List()); + return buf.toBytes(); + } + + /// Builds the offset chain for DynamicAlias2 format. + /// + /// The chain encodes the byte offset and presence of each signal's + /// packed data within the value change section. + Uint8List _buildOffsetChain(List packedSignals) { + final buf = BytesBuilder(copy: false); + var currentOffset = 0; // byte offset within vc section (after pack_type) + var prevOffset = 0; + var consecutiveEmpty = 0; + + // Offset 0 is the pack_type byte itself. Signal data starts at offset 1. + currentOffset = 1; // skip the pack_type byte + + for (var i = 0; i < packedSignals.length; i++) { + final ps = packedSignals[i]; + if (ps.isEmpty) { + consecutiveEmpty++; + } else { + // Flush any consecutive empty signals + if (consecutiveEmpty > 0) { + // Write: varint((count << 1) | 0) — bit0=0 means "zero block" + buf.add(encodeVarint(consecutiveEmpty << 1)); + consecutiveEmpty = 0; + } + // Write positive offset delta (signed varint with bit0=1) + // In DynamicAlias2: bit0=1 + signed_varint >> 1 > 0 means + // new incremental offset delta. + // Encoding: signed_varint((delta << 1) | 1) + // Reader does: shval = read_variant_i64() >> 1 = delta + final offsetDelta = currentOffset - prevOffset; + buf.add(encodeSignedVarint((offsetDelta << 1) | 1)); + prevOffset = currentOffset; + currentOffset += ps.length; + } + } + + // Flush trailing empty signals + if (consecutiveEmpty > 0) { + buf.add(encodeVarint(consecutiveEmpty << 1)); + } + + return buf.toBytes(); + } + + /// Builds the time table section (appended at end of VcData block). + /// + /// The time table is: compressed delta-encoded timestamps, followed by + /// 3 u64s: uncompressed_length, compressed_length, num_entries. + Uint8List _buildTimeTableBytes(List timeTable) { + // Delta-encode the time table + final deltaBuf = BytesBuilder(copy: false); + var prevTime = 0; + for (final t in timeTable) { + deltaBuf.add(encodeVarint(t - prevTime)); + prevTime = t; + } + final uncompressed = deltaBuf.toBytes(); + final compressed = + _zlibCompress(uncompressed, config.compressionLevel, allowRaw: true); + + // Build the full time section: compressed data + 3 u64s + final result = BytesBuilder(copy: false) + ..add(compressed) + ..add(_encodeU64(uncompressed.length)) + ..add(_encodeU64(compressed.length)) + ..add(_encodeU64(timeTable.length)); + return result.toBytes(); + } + + // ─────────────── Low-level I/O helpers ─────────────── + + /// Writes a big-endian u64. + void _writeU64(int value) { + final bd = ByteData(8)..setUint64(0, value); + _file.writeFromSync(bd.buffer.asUint8List()); + } + + /// Encodes a big-endian u64 to bytes. + Uint8List _encodeU64(int value) { + final bd = ByteData(8)..setUint64(0, value); + return bd.buffer.asUint8List(); + } + + /// Writes a little-endian f64 (for double endian test). + void _writeF64LE(double value) { + final bd = ByteData(8)..setFloat64(0, value, Endian.little); + _file.writeFromSync(bd.buffer.asUint8List()); + } + + /// Writes a fixed-length NUL-padded string. + void _writeFixedString(String value, int maxLen) { + final bytes = utf8.encode(value); + final len = bytes.length < maxLen ? bytes.length : maxLen - 1; + _file + ..writeFromSync(bytes.sublist(0, len)) + // Pad with zeros + ..writeFromSync(Uint8List(maxLen - len)); + } + + /// Encodes a NUL-terminated string. + Uint8List _cString(String value) { + final bytes = utf8.encode(value); + final result = Uint8List(bytes.length + 1) + ..setRange(0, bytes.length, bytes); + // last byte is already 0 + return result; + } + + /// Encodes an unsigned integer as LEB128 varint. + static Uint8List encodeVarint(int value) { + if (value < 0) { + throw ArgumentError('Value must be non-negative: $value'); + } + if (value <= 0x7F) { + return Uint8List.fromList([value]); + } + final bytes = []; + var v = value; + while (v != 0) { + final nextV = v >> 7; + final mask = nextV == 0 ? 0 : 0x80; + bytes.add((v & 0x7F) | mask); + v = nextV; + } + return Uint8List.fromList(bytes); + } + + /// Encodes a signed integer as signed LEB128 varint. + static Uint8List encodeSignedVarint(int value) { + if (value >= -64 && value <= 63) { + return Uint8List.fromList([value & 0x7F]); + } + + final bytes = []; + var v = value; + var more = true; + while (more) { + var byte_ = v & 0x7F; + v >>= 7; + // Check if we're done + if ((v == 0 && (byte_ & 0x40) == 0) || (v == -1 && (byte_ & 0x40) != 0)) { + more = false; + } else { + byte_ |= 0x80; + } + bytes.add(byte_); + } + return Uint8List.fromList(bytes); + } + + /// Writes gzip-compressed bytes (gzip header + deflate data). + void _writeGzipCompressed(Uint8List data) { + // Gzip header (10 bytes) + const gzipHeader = [ + 0x1F, 0x8B, // magic + 0x08, // deflate + 0x00, // no flags + 0x00, 0x00, 0x00, 0x00, // timestamp = 0 + 0x00, // compression level + 0xFF, // OS = unknown + ]; + _file.writeFromSync(Uint8List.fromList(gzipHeader)); + + // Deflate-compressed data (raw deflate, not zlib-wrapped) + final compressed = _deflateCompress(data, config.compressionLevel); + _file.writeFromSync(compressed); + } + + /// Compresses bytes using zlib (with zlib header, for geometry/frame/etc). + static Uint8List _zlibCompress(Uint8List data, int level, + {bool allowRaw = false}) { + final compressed = ZLibCodec(level: level).encode(data); + final result = Uint8List.fromList(compressed); + if (allowRaw && result.length >= data.length) { + // Compression didn't help, return uncompressed + return data; + } + return result; + } + + /// Compresses bytes using raw deflate (no zlib header, for gzip hierarchy). + static Uint8List _deflateCompress(Uint8List data, int level) { + final compressed = ZLibCodec(level: level, raw: true).encode(data); + return Uint8List.fromList(compressed); + } + + /// Generates a date string for the header. + String _dateString() { + final now = DateTime.now(); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', // + ]; + final day = days[now.weekday - 1]; + final month = months[now.month - 1]; + final d = now.day.toString().padLeft(2); + final h = now.hour.toString().padLeft(2, '0'); + final m = now.minute.toString().padLeft(2, '0'); + final s = now.second.toString().padLeft(2, '0'); + return '$day $month $d $h:$m:$s ${now.year}\n'; + } +} diff --git a/lib/src/wave_dumper.dart b/lib/src/wave_dumper.dart index 3a37e55ea..033291863 100644 --- a/lib/src/wave_dumper.dart +++ b/lib/src/wave_dumper.dart @@ -1,11 +1,13 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // wave_dumper.dart -// Waveform dumper for a given module hierarchy, dumps to ".vcd" file. +// Waveform dumper for a given module hierarchy, dumps to ".vcd" or ".fst" file. // // 2021 May 7 // Author: Max Korbel +// 2026 February - Added FST format support +// Author: Desmond Kirkpatrick import 'dart:collection'; import 'dart:io'; @@ -15,13 +17,31 @@ import 'package:rohd/src/utilities/sanitizer.dart'; import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; +/// Waveform output format. +enum WaveFormat { + /// VCD (Value Change Dump) — IEEE 1364 standard text format. + vcd, + + /// FST (Fast Signal Trace) — GTKWave binary format. + /// + /// FST files are compressed, support random access, and are compatible + /// with GTKWave, Surfer, and the wellen reader. + fst, +} + /// A waveform dumper for simulations. /// -/// Outputs to vcd format at [outputPath]. [module] must be built prior to -/// attaching the [WaveDumper]. +/// Outputs to VCD or FST format at [outputPath]. [module] must be built prior +/// to attaching the [WaveDumper]. /// /// The waves will only dump to the file periodically and then once the /// simulation has completed. +/// +/// +/// To output FST (compressed binary) instead of VCD (text): +/// ```dart +/// WaveDumper(module, outputPath: 'waves.fst', format: WaveFormat.fst); +/// ``` class WaveDumper { /// The [Module] being dumped. final Module module; @@ -29,13 +49,21 @@ class WaveDumper { /// The output filepath of the generated waveforms. final String outputPath; - /// The file to write dumped output waveform to. - final File _outputFile; - /// A sink to write contents into [_outputFile]. - late final IOSink _outFileSink; + /// The waveform output format (VCD or FST). + final WaveFormat format; + + /// The FST writer configuration (only used when [format] is + /// [WaveFormat.fst]). + final FstWriterConfig? fstConfig; + + /// The file to write dumped output waveform to (VCD only). + File? _outputFile; - /// A buffer for contents before writing to the file sink. + /// A sink to write contents into [_outputFile] (VCD only). + IOSink? _outFileSink; + + /// A buffer for contents before writing to the file sink (VCD only). final StringBuffer _fileBuffer = StringBuffer(); /// A counter for tracking signal names in the VCD file. @@ -44,6 +72,12 @@ class WaveDumper { /// Stores the mapping from [Logic] to signal marker in the VCD file. final Map _signalToMarkerMap = {}; + /// Stores the mapping from [Logic] to FST signal handle (FST only). + final Map _signalToFstHandle = {}; + + /// The FST writer instance (FST only). + FstWriter? _fstWriter; + /// A set of all [Logic]s that have changed in this timestamp so far. /// /// This spans across multiple inject or changed events if they are in the @@ -57,20 +91,28 @@ class WaveDumper { int _currentDumpingTimestamp = Simulator.time; /// Attaches a [WaveDumper] to record all signal changes in a simulation of - /// [module] in a VCD file at [outputPath]. - WaveDumper(this.module, {this.outputPath = 'waves.vcd'}) - : _outputFile = File(outputPath)..createSync(recursive: true) { + /// [module] in a waveform file at [outputPath]. + /// + /// The output [format] defaults to [WaveFormat.vcd] for VCD text files. + /// Set to [WaveFormat.fst] for compressed FST binary files. + /// + WaveDumper( + this.module, { + this.outputPath = 'waves.vcd', + this.format = WaveFormat.vcd, + this.fstConfig, + }) { if (!module.hasBuilt) { throw Exception( 'Module must be built before passed to dumper. Call build() first.'); } - _outFileSink = _outputFile.openWrite(); - - _collectAllSignals(); - _writeHeader(); - _writeScope(); + if (format == WaveFormat.fst) { + _initFst(); + } else { + _initVcd(); + } Simulator.preTick.listen((args) { if (Simulator.time != _currentDumpingTimestamp) { @@ -93,7 +135,82 @@ class WaveDumper { /// write contents to the output file. static const _fileBufferLimit = 100000; - /// Buffers [contents] to be written to the output file. + // ─────────────── VCD initialization ─────────────── + + /// Initializes VCD output. + void _initVcd() { + _outputFile = File(outputPath)..createSync(recursive: true); + _outFileSink = _outputFile!.openWrite(); + _collectAllSignals(); + _writeVcdHeader(); + _writeVcdScope(); + } + + // ─────────────── FST initialization ─────────────── + + /// Initializes FST output. + void _initFst() { + _fstWriter = + FstWriter(outputPath, config: fstConfig ?? const FstWriterConfig()); + + // Walk module hierarchy and declare signals + _collectAllSignalsFst(module); + + // Write header after all signals declared + _fstWriter!.writeHeader(); + + } + + /// Collects signals from the module hierarchy and declares them in the FST + /// writer. + void _collectAllSignalsFst(Module m) { + _fstWriter!.pushScope(m.uniqueInstanceName); + var hasSignals = false; + + final moduleSignalUniquifier = Uniquifier(); + + for (final sig in m.signals) { + if (sig is Const) { + continue; + } + + hasSignals = true; + final baseName = Sanitizer.sanitizeSV(sig.name); + final signalName = moduleSignalUniquifier.getUniqueName( + initialName: baseName, reserved: sig.isPort); + + final handle = _fstWriter!.declareSignal( + signalName, + sig.width, + direction: sig.isPort + ? (sig.isInput ? FstVarDirection.input : FstVarDirection.output) + : FstVarDirection.implicit, + ); + _signalToFstHandle[sig] = handle; + + sig.changed.listen((args) { + _changedLogicsThisTimestamp.add(sig); + }); + } + + for (final subm in m.subModules) { + if (subm is InlineSystemVerilog) { + continue; + } + _collectAllSignalsFst(subm); + } + + // Only pop scope if we had content (matching VCD empty-scope behavior) + if (!hasSignals && + m.subModules.where((s) => s is! InlineSystemVerilog).isEmpty) { + // empty scope — we still need to pop what we pushed + } + _fstWriter!.popScope(); + } + + // ─────────────── Shared methods ─────────────── + + /// Buffers [contents] to be written to the VCD output file. void _writeToBuffer(String contents) { _fileBuffer.write(contents); @@ -102,17 +219,23 @@ class WaveDumper { } } - /// Writes all pending items in the [_fileBuffer] to the file. + /// Writes all pending items in the [_fileBuffer] to the VCD file. void _writeToFile() { - _outFileSink.write(_fileBuffer.toString()); + _outFileSink?.write(_fileBuffer.toString()); _fileBuffer.clear(); } /// Terminates the waveform dumping, including closing the file. Future _terminate() async { - _writeToFile(); - await _outFileSink.flush(); - await _outFileSink.close(); + if (format == WaveFormat.fst) { + // For FST: flush any remaining changes and finalize + _fstWriter?.finish(); + } else { + // For VCD: flush buffer and close file + _writeToFile(); + await _outFileSink?.flush(); + await _outFileSink?.close(); + } } /// Registers all signal value changes to write updates to the dumped VCD. @@ -131,6 +254,7 @@ class WaveDumper { _changedLogicsThisTimestamp.add(sig); }); } + for (final subm in m.subModules) { if (subm is InlineSystemVerilog) { // the InlineSystemVerilog modules are "boring" to inspect @@ -141,8 +265,10 @@ class WaveDumper { } } + // ─────────────── VCD-specific methods ─────────────── + /// Writes the top header for the VCD file. - void _writeHeader() { + void _writeVcdHeader() { final dateString = Timestamper.stamp(); const timescale = '1ps'; final header = ''' @@ -162,12 +288,13 @@ class WaveDumper { /// Writes the scope of the VCD, including signal and hierarchy declarations, /// as well as initial values. - void _writeScope() { + void _writeVcdScope() { var scopeString = _computeScopeString(module); scopeString += '\$enddefinitions \$end\n'; scopeString += '\$dumpvars\n'; _writeToBuffer(scopeString); _signalToMarkerMap.keys.forEach(_writeSignalValueUpdate); + _writeToBuffer('\$end\n'); } @@ -184,12 +311,13 @@ class WaveDumper { final width = sig.width; final marker = _signalToMarkerMap[sig]; - var signalName = Sanitizer.sanitizeSV(sig.name); - signalName = moduleSignalUniquifier.getUniqueName( - initialName: signalName, reserved: sig.isPort); + final baseName = Sanitizer.sanitizeSV(sig.name); + final signalName = moduleSignalUniquifier.getUniqueName( + initialName: baseName, reserved: sig.isPort); innerScopeString .write(' $padding\$var wire $width $marker $signalName \$end\n'); } + for (final subModule in m.subModules) { innerScopeString .write(_computeScopeString(subModule, indent: indent + 1)); @@ -203,8 +331,19 @@ class WaveDumper { return scopeString; } - /// Writes the current timestamp to the VCD. + // ─────────────── Timestamp capture ─────────────── + + /// Captures all signal changes at the current timestamp. void _captureTimestamp(int timestamp) { + if (format == WaveFormat.fst) { + _captureTimestampFst(timestamp); + } else { + _captureTimestampVcd(timestamp); + } + } + + /// Captures a VCD timestamp: writes the timestamp marker and changed values. + void _captureTimestampVcd(int timestamp) { final timestampString = '#$timestamp\n'; _writeToBuffer(timestampString); @@ -213,6 +352,23 @@ class WaveDumper { ..clear(); } + /// Captures an FST timestamp: emits value changes for all changed signals. + void _captureTimestampFst(int timestamp) { + for (final sig in _changedLogicsThisTimestamp) { + final handle = _signalToFstHandle[sig]; + if (handle == null) { + continue; + } + + final binaryValue = sig.value.reversed + .toList() + .map((e) => e.toString(includeWidth: false)) + .join(); + _fstWriter!.emitValueChange(timestamp, handle, binaryValue); + } + _changedLogicsThisTimestamp.clear(); + } + /// Writes the current value of [signal] to the VCD. void _writeSignalValueUpdate(Logic signal) { final binaryValue = signal.value.reversed diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart new file mode 100644 index 000000000..e817d7259 --- /dev/null +++ b/test/fst_writer_test.dart @@ -0,0 +1,419 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_writer_test.dart +// Tests for FST writer and WaveDumper FST format support. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import 'pipeline_test.dart' show SimplePipelineModule; + +/// A simple module for testing. +class _SimpleModule extends Module { + _SimpleModule(Logic a) { + a = addInput('a', a); + addOutput('b') <= a; + } +} + +/// A module with multi-bit signals for testing. +class _MultiBitModule extends Module { + _MultiBitModule(Logic a, Logic clk) { + a = addInput('a', a, width: a.width); + final aClk = addInput('clk', clk); + addOutput('q', width: a.width) <= FlipFlop(aClk, a).q; + } +} + +const _tempDumpDir = 'tmp_test'; + +/// Gets the path of the FST file based on a name. +String _temporaryFstPath(String name) => '$_tempDumpDir/temp_dump_$name.fst'; + +/// Attaches a [WaveDumper] to [module] with FST format. +void _createFstDump(Module module, String name) { + Directory(_tempDumpDir).createSync(recursive: true); + final tmpDumpFile = _temporaryFstPath(name); + WaveDumper(module, + outputPath: tmpDumpFile, format: WaveFormat.fst); +} + +/// Deletes the temporary FST file associated with [name]. +void _deleteFstDump(String name) { + final tmpDumpFile = _temporaryFstPath(name); + if (File(tmpDumpFile).existsSync()) { + File(tmpDumpFile).deleteSync(); + } +} + +/// Reads a big-endian u64 from [data] at [offset]. +int _readU64(Uint8List data, int offset) { + var result = 0; + for (var i = 0; i < 8; i++) { + result = (result << 8) | data[offset + i]; + } + return result; +} + +/// Parses FST file blocks and returns a map of block types to counts. +Map _parseFstBlocks(Uint8List data) { + final blocks = {}; + var pos = 0; + while (pos < data.length) { + final blockType = data[pos]; + pos++; + if (pos + 8 > data.length) { + break; + } + final sectionLength = _readU64(data, pos); + blocks[blockType] = (blocks[blockType] ?? 0) + 1; + pos += sectionLength; + if (sectionLength == 0) { + break; + } + } + return blocks; +} + +/// Parses FST header and returns key fields. +Map _parseFstHeader(Uint8List data) { + // Skip block type byte (0) + if (data[0] != 0) { + throw FormatException('Expected header block type 0, got ${data[0]}'); + } + final sectionLength = _readU64(data, 1); + if (sectionLength != 329) { + throw FormatException( + 'Expected header section length 329, got $sectionLength'); + } + return { + 'start_time': _readU64(data, 9), + 'end_time': _readU64(data, 17), + // skip double_endian_test (8 bytes at offset 25) + 'scope_count': _readU64(data, 41), + 'var_count': _readU64(data, 49), + 'max_var_id': _readU64(data, 57), + 'vc_section_count': _readU64(data, 65), + 'timescale_exponent': data[73], // offset 73 = 1 + 8*9 + }; +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + group('FstWriter unit tests', () { + test('writes valid header block', () { + const path = '$_tempDumpDir/fst_header_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + FstWriter(path) + ..pushScope('top') + ..declareSignal('clk', 1) + ..declareSignal('data', 8) + ..popScope() + ..finish(); + + final data = File(path).readAsBytesSync(); + expect(data[0], equals(0), reason: 'First byte should be header type'); + final sectionLength = _readU64(data, 1); + expect(sectionLength, equals(329), reason: 'Header is 329 bytes'); + + // Parse header fields + final header = _parseFstHeader(data); + expect(header['scope_count'], equals(1)); + expect(header['var_count'], equals(2)); + expect(header['max_var_id'], equals(2)); + + File(path).deleteSync(); + }); + + test('writes all required block types', () { + const path = '$_tempDumpDir/fst_blocks_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + final writer = FstWriter(path)..pushScope('top'); + final clk = writer.declareSignal('clk', 1); + writer + ..popScope() + ..writeHeader() + ..emitValueChange(0, clk, '0') + ..emitValueChange(5, clk, '1') + ..finish(); + + final data = File(path).readAsBytesSync(); + final blocks = _parseFstBlocks(data); + + // Must have: Header(0), VcDataDynamicAlias2(8), Geometry(3), + // Hierarchy(4) + expect(blocks.containsKey(0), isTrue, reason: 'Must have header'); + expect(blocks.containsKey(8), isTrue, reason: 'Must have VcData block'); + expect(blocks.containsKey(3), isTrue, reason: 'Must have geometry'); + expect(blocks.containsKey(4), isTrue, reason: 'Must have hierarchy'); + + File(path).deleteSync(); + }); + + test('geometry encodes signal widths correctly', () { + const path = '$_tempDumpDir/fst_geometry_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + FstWriter(path) + ..pushScope('top') + ..declareSignal('bit1', 1) + ..declareSignal('byte8', 8) + ..declareSignal('word32', 32) + ..popScope() + ..finish(); + + final data = File(path).readAsBytesSync(); + + // Find the geometry block (type 3) + var pos = 0; + while (pos < data.length) { + if (data[pos] == 3) { + // Geometry block + final sectionLength = _readU64(data, pos + 1); + final maxHandle = _readU64(data, pos + 1 + 16); + expect(maxHandle, equals(3)); + + // Geometry data is after section_length(8) + unc_len(8) + + // max_handle(8) = 24 bytes from section_length start + // May be compressed, so just check the block exists + expect(sectionLength, greaterThan(24)); + break; + } + pos++; + if (pos + 8 > data.length) { + break; + } + final sl = _readU64(data, pos); + pos += sl; + if (sl == 0) { + break; + } + } + + File(path).deleteSync(); + }); + }); + + group('WaveDumper FST format', () { + test('basic 1-bit signal FST dump', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dumpName = 'fstBasic'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(100); + await Simulator.run(); + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + final data = fstFile.readAsBytesSync(); + // File should have valid FST header + expect(data[0], equals(0), reason: 'First byte is header block type'); + expect(_readU64(data, 1), equals(329)); + + // Check blocks are present + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue, reason: 'header'); + expect(blocks.containsKey(3), isTrue, reason: 'geometry'); + expect(blocks.containsKey(4), isTrue, reason: 'hierarchy'); + + _deleteFstDump(dumpName); + }); + + test('multi-bit signal FST dump', () async { + final a = Logic(name: 'a', width: 8); + final clk = SimpleClockGenerator(10).clk; + final mod = _MultiBitModule(a, clk); + await mod.build(); + + const dumpName = 'fstMultiBit'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(100); + unawaited(Simulator.run()); + + await clk.nextPosedge; + a.inject(0xAB); + await clk.nextPosedge; + a.inject(0xFF); + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + final data = fstFile.readAsBytesSync(); + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue); + expect(blocks.containsKey(8), isTrue, + reason: 'VcData block with changes'); + + _deleteFstDump(dumpName); + }); + + test('FST file creates non-existent directories', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dir1Path = '$_tempDumpDir/fst_dir1'; + const fstPath = '$dir1Path/dir2/waves.fst'; + + WaveDumper(mod, + outputPath: fstPath, format: WaveFormat.fst); + + a.put(0); + Simulator.setMaxSimTime(10); + await Simulator.run(); + + expect(File(fstPath).existsSync(), isTrue); + + if (Directory(dir1Path).existsSync()) { + Directory(dir1Path).deleteSync(recursive: true); + } + }); + + test('FST header has correct signal counts', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dumpName = 'fstCounts'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(10); + await Simulator.run(); + + final data = File(_temporaryFstPath(dumpName)).readAsBytesSync(); + final header = _parseFstHeader(data); + + // _SimpleModule has 2 signals: input 'a' and output 'b' + expect(header['var_count'], equals(2)); + + _deleteFstDump(dumpName); + }); + + test('FST and VCD both produce output', () async { + // Create a module + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + // Dump as FST + const fstName = 'fstCompare'; + _createFstDump(mod, fstName); + + a.put(0); + Simulator.setMaxSimTime(50); + unawaited(Simulator.run()); + + a.inject(1); + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(fstName)); + expect(fstFile.existsSync(), isTrue); + final fstSize = fstFile.lengthSync(); + expect(fstSize, greaterThan(330), reason: 'FST should be > header size'); + + _deleteFstDump(fstName); + + // Reset and dump as VCD + await Simulator.reset(); + + final a2 = Logic(name: 'a'); + final mod2 = _SimpleModule(a2); + await mod2.build(); + + const vcdPath = '$_tempDumpDir/temp_dump_vcdCompare.vcd'; + Directory(_tempDumpDir).createSync(recursive: true); + WaveDumper(mod2, outputPath: vcdPath); + + a2.put(0); + Simulator.setMaxSimTime(50); + unawaited(Simulator.run()); + + a2.inject(1); + + await Simulator.simulationEnded; + + final vcdFile = File(vcdPath); + expect(vcdFile.existsSync(), isTrue); + expect(vcdFile.lengthSync(), greaterThan(0)); + + vcdFile.deleteSync(); + }); + + test('pipeline FST has VcData and is readable by fst2vcd', () async { + // Build a 3-stage 8-bit pipeline that generates many signal changes. + final a = Logic(name: 'a', width: 8); + final mod = SimplePipelineModule(a); + await mod.build(); + + const dumpName = 'fstPipeline'; + _createFstDump(mod, dumpName); + + // Drive 200 clock cycles worth of incrementing inputs. + // The 10ps clock gives 2000ps total, producing many VcData changes. + a.put(0); + Simulator.setMaxSimTime(2000); + unawaited(Simulator.run()); + + // Inject a new value every 10ps to keep signals active + for (var i = 1; i <= 200; i++) { + await Future.delayed(Duration.zero); + a.inject(i & 0xFF); + } + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + // File should be substantially larger than just the header (329 bytes) + final fileSize = fstFile.lengthSync(); + expect(fileSize, greaterThan(600), + reason: 'Pipeline FST should have VcData content'); + + // Parse blocks: must include at least one VcData block (type 8) + final data = fstFile.readAsBytesSync(); + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue, reason: 'header block'); + expect(blocks.containsKey(8), isTrue, reason: 'VcData block'); + expect(blocks.containsKey(3), isTrue, reason: 'geometry block'); + expect(blocks.containsKey(4), isTrue, reason: 'hierarchy block'); + + // Validate with fst2vcd (GTKWave tool) if available. + final fst2vcd = Process.runSync('which', ['fst2vcd']); + if (fst2vcd.exitCode == 0) { + final result = Process.runSync('fst2vcd', [fstFile.path]); + expect(result.exitCode, equals(0), + reason: 'fst2vcd failed: ${result.stdout}\n${result.stderr}'); + final vcdOutput = result.stdout as String; + expect(vcdOutput, contains(r'$timescale'), + reason: 'fst2vcd output should be valid VCD'); + } + + _deleteFstDump(dumpName); + }); + }); +} From 4895f32247273faf1d46abab44aa6672794301bc Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 22 Jun 2026 05:29:30 -0700 Subject: [PATCH 06/11] format issues --- lib/src/wave_dumper.dart | 3 --- test/fst_writer_test.dart | 6 ++---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/src/wave_dumper.dart b/lib/src/wave_dumper.dart index 033291863..1e426f02a 100644 --- a/lib/src/wave_dumper.dart +++ b/lib/src/wave_dumper.dart @@ -49,7 +49,6 @@ class WaveDumper { /// The output filepath of the generated waveforms. final String outputPath; - /// The waveform output format (VCD or FST). final WaveFormat format; @@ -107,7 +106,6 @@ class WaveDumper { 'Module must be built before passed to dumper. Call build() first.'); } - if (format == WaveFormat.fst) { _initFst(); } else { @@ -158,7 +156,6 @@ class WaveDumper { // Write header after all signals declared _fstWriter!.writeHeader(); - } /// Collects signals from the module hierarchy and declares them in the FST diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart index e817d7259..89bc442b6 100644 --- a/test/fst_writer_test.dart +++ b/test/fst_writer_test.dart @@ -42,8 +42,7 @@ String _temporaryFstPath(String name) => '$_tempDumpDir/temp_dump_$name.fst'; void _createFstDump(Module module, String name) { Directory(_tempDumpDir).createSync(recursive: true); final tmpDumpFile = _temporaryFstPath(name); - WaveDumper(module, - outputPath: tmpDumpFile, format: WaveFormat.fst); + WaveDumper(module, outputPath: tmpDumpFile, format: WaveFormat.fst); } /// Deletes the temporary FST file associated with [name]. @@ -277,8 +276,7 @@ void main() { const dir1Path = '$_tempDumpDir/fst_dir1'; const fstPath = '$dir1Path/dir2/waves.fst'; - WaveDumper(mod, - outputPath: fstPath, format: WaveFormat.fst); + WaveDumper(mod, outputPath: fstPath, format: WaveFormat.fst); a.put(0); Simulator.setMaxSimTime(10); From cd713c84511f3a8f7640fe20416478a5f3534301 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 22 Jun 2026 05:48:14 -0700 Subject: [PATCH 07/11] format issues --- lib/src/fst/fst_writer.dart | 77 ++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/lib/src/fst/fst_writer.dart b/lib/src/fst/fst_writer.dart index 8f31500fa..11849a5d7 100644 --- a/lib/src/fst/fst_writer.dart +++ b/lib/src/fst/fst_writer.dart @@ -246,8 +246,11 @@ class FstWriter { } /// Pushes a new scope onto the hierarchy. - void pushScope(String name, - {FstScopeType type = FstScopeType.module, String component = ''}) { + void pushScope( + String name, { + FstScopeType type = FstScopeType.module, + String component = '', + }) { _hierEntries.add(_ScopeEntry(type, name, component: component)); _scopeCount++; } @@ -393,12 +396,14 @@ class FstWriter { final blockEndPos = _file.positionSync(); // Record block in the index for read-back - _blockIndex.add(FstBlockIndex( - fileOffset: blockOffset, - sectionLength: blockEndPos - blockOffset - 1, - startTime: blockStart, - endTime: blockEnd, - )); + _blockIndex.add( + FstBlockIndex( + fileOffset: blockOffset, + sectionLength: blockEndPos - blockOffset - 1, + startTime: blockStart, + endTime: blockEnd, + ), + ); _vcSectionCount++; // Update global time range @@ -417,7 +422,7 @@ class FstWriter { /// Index of all flushed VcData blocks. /// /// Each entry contains the file offset and time range, enabling - /// the [FstBlockReader] to read specific blocks on demand. + /// the `FstBlockReader` to read specific blocks on demand. List get blockIndex => List.unmodifiable(_blockIndex); /// Number of declared signals. @@ -428,7 +433,7 @@ class FstWriter { .map((s) => FstSignalInfo(name: s.name, width: s.width, isReal: s.isReal)) .toList(); - /// The output file handle for read-back by [FstBlockReader]. + /// The output file handle for read-back by `FstBlockReader`. /// /// **Warning**: The caller must not close or modify the file position /// without restoring it. The writer uses this same handle for writing. @@ -444,10 +449,12 @@ class FstWriter { int endTime, ) => _changes - .where((c) => - c.handleIndex == handleIndex && - c.time >= startTime && - c.time <= endTime) + .where( + (c) => + c.handleIndex == handleIndex && + c.time >= startTime && + c.time <= endTime, + ) .map((c) => (time: c.time, value: c.value)) .toList(); @@ -493,7 +500,8 @@ class FstWriter { // end_time(8) + endian_test(8) + memory_used(8) + scope_count(8) + // var_count(8) + max_var_id(8) = offset 65 _file.setPositionSync( - 1 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8); // at vc_section_count + 1 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8, + ); // at vc_section_count _writeU64(_vcSectionCount); _file.setPositionSync(savedPos); } @@ -530,8 +538,10 @@ class FstWriter { } final uncompressed = buf.toBytes(); - assert(handleCount == _signals.length, - 'Handle count mismatch: $handleCount vs ${_signals.length}'); + assert( + handleCount == _signals.length, + 'Handle count mismatch: $handleCount vs ${_signals.length}', + ); // Write as FST_BL_HIER (type 4) with gzip compression _file.writeByteSync(FstBlockType.hierarchy.value); @@ -560,8 +570,11 @@ class FstWriter { buf.add(encodeVarint(sig.geometryValue)); } final uncompressed = buf.toBytes(); - final compressed = - _zlibCompress(uncompressed, config.compressionLevel, allowRaw: true); + final compressed = _zlibCompress( + uncompressed, + config.compressionLevel, + allowRaw: true, + ); _file.writeByteSync(FstBlockType.geometry.value); final sectionLength = 3 * 8 + compressed.length; @@ -623,8 +636,11 @@ class FstWriter { // Build frame bytes final frameBytes = _buildFrameBytes(frameValues); - final frameCompressed = - _zlibCompress(frameBytes, config.compressionLevel, allowRaw: true); + final frameCompressed = _zlibCompress( + frameBytes, + config.compressionLevel, + allowRaw: true, + ); // Build the signal offset chain (DynamicAlias2 format) final chainBytes = _buildOffsetChain(packedSignals); @@ -652,7 +668,6 @@ class FstWriter { ..writeFromSync(encodeVarint(frameCompressed.length)) // comp len ..writeFromSync(encodeVarint(_signals.length)) // max_handle ..writeFromSync(frameCompressed) - // Value change section ..writeFromSync(encodeVarint(_signals.length)) // max_handle ..writeByteSync(0x5A); // pack_type = 'Z' (zlib) @@ -702,7 +717,9 @@ class FstWriter { /// Each byte array contains the encoded value change chain for that signal. /// Changes at [blockStartTime] are skipped (captured in the frame). List _buildSignalData( - Map timeToIndex, int blockStartTime) { + Map timeToIndex, + int blockStartTime, + ) { // Group changes by signal handle index final signalChanges = List>.generate( _signals.length, @@ -872,8 +889,11 @@ class FstWriter { prevTime = t; } final uncompressed = deltaBuf.toBytes(); - final compressed = - _zlibCompress(uncompressed, config.compressionLevel, allowRaw: true); + final compressed = _zlibCompress( + uncompressed, + config.compressionLevel, + allowRaw: true, + ); // Build the full time section: compressed data + 3 u64s final result = BytesBuilder(copy: false) @@ -984,8 +1004,11 @@ class FstWriter { } /// Compresses bytes using zlib (with zlib header, for geometry/frame/etc). - static Uint8List _zlibCompress(Uint8List data, int level, - {bool allowRaw = false}) { + static Uint8List _zlibCompress( + Uint8List data, + int level, { + bool allowRaw = false, + }) { final compressed = ZLibCodec(level: level).encode(data); final result = Uint8List.fromList(compressed); if (allowRaw && result.length >= data.length) { From a7737465697ceec55ab3a446317a0ce7480ceecb Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 22 Jun 2026 06:01:34 -0700 Subject: [PATCH 08/11] Restrict fst_writer_test to VM platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses Directory.createSync / dart:io — fails on JS with _Namespace error. --- test/fst_writer_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart index 89bc442b6..7374a2239 100644 --- a/test/fst_writer_test.dart +++ b/test/fst_writer_test.dart @@ -7,6 +7,9 @@ // 2026 February // Author: Desmond Kirkpatrick +@TestOn('vm') +library; + import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; From a6e545760412db9c48db5e0c2d40d974fc6ea3f4 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 15 Jul 2026 16:10:17 -0700 Subject: [PATCH 09/11] narrow the fst PR -- it should come first --- .devcontainer/devcontainer.json | 2 +- .github/configs/mlc_config.json | 6 + .github/workflows/build_devtool.yml | 5 +- .github/workflows/general.yml | 5 +- CHANGELOG.md | 6 +- CONTRIBUTING.md | 2 +- analysis_options.yaml | 2 + devtools_options.yaml | 4 + doc/user_guide/_docs/A21-generation.md | 15 + lib/src/fst/fst_writer.dart | 4 +- lib/src/module.dart | 14 +- lib/src/modules/bus.dart | 119 +- lib/src/signals/logic.dart | 21 + .../systemverilog/systemverilog.dart | 3 +- ...systemverilog_synth_module_definition.dart | 403 ++- .../systemverilog_synthesis_result.dart | 58 +- .../systemverilog_synthesizer.dart | 14 +- ...stemverilog_synthesizer_configuration.dart | 32 + .../utilities/synth_assignment.dart | 29 + .../synthesizers/utilities/synth_logic.dart | 82 +- .../utilities/synth_module_definition.dart | 2201 ++++++++++++- lib/src/utilities/simcompare.dart | 13 +- packages/rohd_hierarchy/LICENSE | 28 + packages/rohd_hierarchy/README.md | 246 ++ packages/rohd_hierarchy/analysis_options.yaml | 1 + .../rohd_hierarchy/lib/rohd_hierarchy.dart | 50 + .../lib/src/base_hierarchy_adapter.dart | 80 + .../lib/src/hierarchy_constants.dart | 14 + .../lib/src/hierarchy_models.dart | 16 + .../lib/src/hierarchy_occurrence.dart | 258 ++ .../lib/src/hierarchy_query.dart | 152 + .../lib/src/hierarchy_search_controller.dart | 216 ++ .../lib/src/hierarchy_search_result.dart | 63 + .../lib/src/hierarchy_service.dart | 875 +++++ .../lib/src/netlist_hierarchy_adapter.dart | 224 ++ .../lib/src/occurrence_address.dart | 144 + .../lib/src/occurrence_search_result.dart | 45 + .../rohd_hierarchy/lib/src/prefix_query.dart | 61 + .../rohd_hierarchy/lib/src/regex_query.dart | 177 + .../lib/src/signal_occurrence.dart | 274 ++ .../lib/src/signal_search_result.dart | 49 + packages/rohd_hierarchy/pubspec.yaml | 19 + .../test/adapter_search_parity_test.dart | 285 ++ .../test/address_conversion_test.dart | 286 ++ .../test/devtools_search_flow_test.dart | 214 ++ .../test/filter_bank_integration_test.dart | 719 ++++ .../test/fixtures/filter_bank.json | 1183 +++++++ .../hierarchy_path_vs_signal_id_test.dart | 194 ++ .../test/hierarchy_query_test.dart | 655 ++++ .../hierarchy_search_controller_test.dart | 505 +++ .../test/module_search_test.dart | 325 ++ .../test/occurrence_address_test.dart | 335 ++ .../test/regex_search_test.dart | 546 +++ .../test/rohd_signal_resolve_test.dart | 72 + .../test/signal_search_result_test.dart | 236 ++ rohd_devtools_extension/.vscode/launch.json | 11 + rohd_devtools_extension/.vscode/tasks.json | 137 + rohd_devtools_extension/LICENSE | 28 + rohd_devtools_extension/Makefile | 23 + rohd_devtools_extension/README.md | 88 +- rohd_devtools_extension/analysis_options.yaml | 270 +- .../assets/help/details_help.md | 28 + .../assets/help/devtools_help.md | 34 + .../assets/icons/rohd_logo.png | Bin 0 -> 5225 bytes rohd_devtools_extension/lib/main.dart | 108 +- .../lib/main_standalone.dart | 52 + .../lib/rohd_devtools/const/app_theme.dart | 193 ++ .../lib/rohd_devtools/cubit/cubits.dart | 13 + .../cubit/details_tab_cubit.dart | 31 + .../cubit/rohd_service_cubit.dart | 221 +- .../cubit/rohd_service_state.dart | 14 +- .../cubit/selected_module_cubit.dart | 7 +- .../cubit/selected_module_state.dart | 8 +- .../cubit/signal_search_term_cubit.dart | 5 +- .../rohd_devtools/cubit/snapshot_cubit.dart | 233 ++ .../lib/rohd_devtools/cubit/theme_cubit.dart | 39 + .../cubit/tree_search_term_cubit.dart | 5 +- .../models/dtd_vm_service_info.dart | 74 + .../rohd_devtools/models/signal_model.dart | 43 +- .../lib/rohd_devtools/models/tree_model.dart | 82 +- .../lib/rohd_devtools/rohd_devtools.dart | 5 +- .../services/connection_state_machine.dart | 618 ++++ .../services/io_vm_connection_strategy.dart | 145 + .../platform_vm_connection_strategy.dart | 21 + .../platform_vm_connection_strategy_stub.dart | 22 + .../services/service_manager_bridge.dart | 11 + .../services/service_manager_bridge_io.dart | 15 + .../services/service_manager_bridge_web.dart | 11 + .../lib/rohd_devtools/services/services.dart | 16 + .../services/signal_service.dart | 8 +- .../services/signal_value_source.dart | 42 + .../services/signal_value_source_binding.dart | 30 + .../rohd_devtools/services/tree_service.dart | 86 +- .../vm_service_signal_value_source.dart | 382 +++ .../services/web_vm_connection_strategy.dart | 171 + .../rohd_devtools/ui/details_help_button.dart | 40 + .../lib/rohd_devtools/ui/devtool_appbar.dart | 90 +- .../ui/devtools_connection_host.dart | 1568 +++++++++ .../ui/devtools_help_button.dart | 41 + .../rohd_devtools/ui/module_tree_card.dart | 140 +- .../ui/module_tree_details_navbar.dart | 147 +- .../lib/rohd_devtools/ui/platform_icon.dart | 118 + .../lib/rohd_devtools/ui/schematic_icon.dart | 125 + .../rohd_devtools/ui/signal_details_card.dart | 218 +- .../lib/rohd_devtools/ui/signal_table.dart | 203 +- .../ui/signal_table_text_field.dart | 122 +- .../ui/simulation_time_display.dart | 30 + .../ui/standalone_app_shell.dart | 380 +++ .../lib/rohd_devtools/ui/ui.dart | 21 + .../rohd_devtools/ui/vm_connection_form.dart | 729 +++++ .../view/rohd_devtools_page.dart | 66 +- .../view/tree_structure_page.dart | 391 ++- .../lib/rohd_devtools_observer.dart | 5 +- rohd_devtools_extension/linux/.gitignore | 1 + rohd_devtools_extension/linux/CMakeLists.txt | 138 + .../linux/flutter/CMakeLists.txt | 88 + .../flutter/generated_plugin_registrant.cc | 15 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 24 + .../linux/runner/CMakeLists.txt | 26 + rohd_devtools_extension/linux/runner/main.cc | 6 + .../linux/runner/my_application.cc | 144 + .../linux/runner/my_application.h | 18 + .../packages/rohd_devtools_widgets/LICENSE | 28 + .../packages/rohd_devtools_widgets/README.md | 13 + .../analysis_options.yaml | 1 + .../lib/rohd_devtools_widgets.dart | 41 + .../lib/src/app_bar_overlay.dart | 168 + .../lib/src/bit_expansion_menu.dart | 139 + .../lib/src/bit_field_utils.dart | 257 ++ .../lib/src/capture_boundary.dart | 83 + .../lib/src/cross_probe_button.dart | 48 + .../lib/src/cross_probe_menu.dart | 253 ++ .../lib/src/cross_probe_service.dart | 157 + .../lib/src/export_button.dart | 53 + .../lib/src/export_toast.dart | 48 + .../lib/src/logic_type_utils.dart | 408 +++ .../lib/src/markdown_help_button.dart | 486 +++ .../lib/src/rohd_extension_client.dart | 133 + .../lib/src/rohd_extension_status.dart | 223 ++ .../lib/src/save_png_native.dart | 20 + .../lib/src/save_png_stub.dart | 14 + .../lib/src/save_png_web.dart | 32 + .../rohd_devtools_widgets/pubspec.yaml | 18 + .../test/logic_type_utils_test.dart | 40 + rohd_devtools_extension/pubspec.yaml | 36 +- .../fixtures/tree_model.stub.dart | 98 +- .../tree_structure/model_tree_card_test.dart | 41 +- .../tree_structure_page_test.dart | 96 +- .../tool/test_devtools_install.dart | 231 ++ rohd_devtools_extension/web/favicon.png | Bin 917 -> 952 bytes .../web/icons/Icon-192.png | Bin 5292 -> 4911 bytes .../web/icons/Icon-512.png | Bin 8252 -> 50043 bytes .../web/icons/Icon-maskable-192.png | Bin 5594 -> 4911 bytes .../web/icons/Icon-maskable-512.png | Bin 20998 -> 50043 bytes rohd_devtools_extension/web/index.html | 7 - rohd_devtools_extension/web/manifest.json | 35 - rohd_extension/.markdownlint.json | 4 + rohd_extension/Makefile | 108 + rohd_extension/README.md | 358 ++ rohd_extension/dart/LICENSE | 28 + rohd_extension/dart/lib/dtd_service.dart | 147 + rohd_extension/dart/lib/flc_data.dart | 429 +++ .../dart/lib/rohd_source_navigator.dart | 11 + rohd_extension/dart/lib/source_navigator.dart | 217 ++ rohd_extension/dart/pubspec.yaml | 17 + rohd_extension/dart/test/flc_data_test.dart | 405 +++ rohd_extension/package.json | 102 + rohd_extension/resources/rohd_icon.png | Bin 0 -> 5225 bytes rohd_extension/snippets/rohd.json | 385 +++ rohd_extension/src/conditional_completions.ts | 1087 ++++++ rohd_extension/src/debug_tracker.ts | 384 +++ rohd_extension/src/dtd_bridge.ts | 439 +++ rohd_extension/src/extension.ts | 113 + rohd_extension/src/flc_service.ts | 515 +++ rohd_extension/src/source_navigator.ts | 482 +++ rohd_extension/src/uri_forwarder.ts | 295 ++ rohd_extension/tool/install.sh | 58 + rohd_extension/tsconfig.json | 15 + test/array_collapsing_test.dart | 2916 ++++++++++++++++- test/benchmark_test.dart | 2 +- test/logic_array_test.dart | 10 +- test/logic_test.dart | 22 + test/naming_cases_test.dart | 4 +- test/net_bus_test.dart | 14 +- test/pair_interface_hier_test.dart | 6 +- test/pair_interface_hier_w_modify_test.dart | 6 +- test/pair_interface_test.dart | 4 +- test/provider_consumer_test.dart | 46 +- test/provider_consumer_w_modify_test.dart | 46 +- test/sv_gen_test.dart | 4 +- test/swizzle_test.dart | 207 +- test/systemverilog_port_types_test.dart | 147 + test/typed_port_test.dart | 29 +- tool/gh_actions/devtool/build_web.sh | 18 - tool/gh_actions/devtool/install_devtools.sh | 65 + .../devtool/test_devtools_install.sh | 62 + tool/run_checks.sh | 4 +- 198 files changed, 31138 insertions(+), 1106 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/src/synthesizers/systemverilog/systemverilog_synthesizer_configuration.dart create mode 100644 packages/rohd_hierarchy/LICENSE create mode 100644 packages/rohd_hierarchy/README.md create mode 100644 packages/rohd_hierarchy/analysis_options.yaml create mode 100644 packages/rohd_hierarchy/lib/rohd_hierarchy.dart create mode 100644 packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_constants.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_models.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_query.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart create mode 100644 packages/rohd_hierarchy/lib/src/hierarchy_service.dart create mode 100644 packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart create mode 100644 packages/rohd_hierarchy/lib/src/occurrence_address.dart create mode 100644 packages/rohd_hierarchy/lib/src/occurrence_search_result.dart create mode 100644 packages/rohd_hierarchy/lib/src/prefix_query.dart create mode 100644 packages/rohd_hierarchy/lib/src/regex_query.dart create mode 100644 packages/rohd_hierarchy/lib/src/signal_occurrence.dart create mode 100644 packages/rohd_hierarchy/lib/src/signal_search_result.dart create mode 100644 packages/rohd_hierarchy/pubspec.yaml create mode 100644 packages/rohd_hierarchy/test/adapter_search_parity_test.dart create mode 100644 packages/rohd_hierarchy/test/address_conversion_test.dart create mode 100644 packages/rohd_hierarchy/test/devtools_search_flow_test.dart create mode 100644 packages/rohd_hierarchy/test/filter_bank_integration_test.dart create mode 100644 packages/rohd_hierarchy/test/fixtures/filter_bank.json create mode 100644 packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart create mode 100644 packages/rohd_hierarchy/test/hierarchy_query_test.dart create mode 100644 packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart create mode 100644 packages/rohd_hierarchy/test/module_search_test.dart create mode 100644 packages/rohd_hierarchy/test/occurrence_address_test.dart create mode 100644 packages/rohd_hierarchy/test/regex_search_test.dart create mode 100644 packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart create mode 100644 packages/rohd_hierarchy/test/signal_search_result_test.dart create mode 100644 rohd_devtools_extension/.vscode/tasks.json create mode 100644 rohd_devtools_extension/LICENSE create mode 100644 rohd_devtools_extension/Makefile create mode 100644 rohd_devtools_extension/assets/help/details_help.md create mode 100644 rohd_devtools_extension/assets/help/devtools_help.md create mode 100644 rohd_devtools_extension/assets/icons/rohd_logo.png create mode 100644 rohd_devtools_extension/lib/main_standalone.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/snapshot_cubit.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/services.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source_binding.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/simulation_time_display.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart create mode 100644 rohd_devtools_extension/linux/.gitignore create mode 100644 rohd_devtools_extension/linux/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/flutter/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugins.cmake create mode 100644 rohd_devtools_extension/linux/runner/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/runner/main.cc create mode 100644 rohd_devtools_extension/linux/runner/my_application.cc create mode 100644 rohd_devtools_extension/linux/runner/my_application.h create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/LICENSE create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/README.md create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_client.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_status.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_native.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_stub.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_web.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/pubspec.yaml create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/test/logic_type_utils_test.dart create mode 100644 rohd_devtools_extension/tool/test_devtools_install.dart delete mode 100644 rohd_devtools_extension/web/manifest.json create mode 100644 rohd_extension/.markdownlint.json create mode 100644 rohd_extension/Makefile create mode 100644 rohd_extension/README.md create mode 100644 rohd_extension/dart/LICENSE create mode 100644 rohd_extension/dart/lib/dtd_service.dart create mode 100644 rohd_extension/dart/lib/flc_data.dart create mode 100644 rohd_extension/dart/lib/rohd_source_navigator.dart create mode 100644 rohd_extension/dart/lib/source_navigator.dart create mode 100644 rohd_extension/dart/pubspec.yaml create mode 100644 rohd_extension/dart/test/flc_data_test.dart create mode 100644 rohd_extension/package.json create mode 100644 rohd_extension/resources/rohd_icon.png create mode 100644 rohd_extension/snippets/rohd.json create mode 100644 rohd_extension/src/conditional_completions.ts create mode 100644 rohd_extension/src/debug_tracker.ts create mode 100644 rohd_extension/src/dtd_bridge.ts create mode 100644 rohd_extension/src/extension.ts create mode 100644 rohd_extension/src/flc_service.ts create mode 100644 rohd_extension/src/source_navigator.ts create mode 100644 rohd_extension/src/uri_forwarder.ts create mode 100755 rohd_extension/tool/install.sh create mode 100644 rohd_extension/tsconfig.json create mode 100644 test/systemverilog_port_types_test.dart delete mode 100755 tool/gh_actions/devtool/build_web.sh create mode 100755 tool/gh_actions/devtool/install_devtools.sh create mode 100755 tool/gh_actions/devtool/test_devtools_install.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5c23bb9c0..12e172c74 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "updateContentCommand": "tool/gh_codespaces/run_setup.sh", diff --git a/.github/configs/mlc_config.json b/.github/configs/mlc_config.json index e041e169a..0c4421967 100644 --- a/.github/configs/mlc_config.json +++ b/.github/configs/mlc_config.json @@ -12,6 +12,12 @@ { "pattern":"^https://github.com" }, + { + "pattern":"^https://pymtl3.readthedocs.io/en/latest/$" + }, + { + "pattern":"^https://docs.cocotb.org/en/stable/$" + }, { "pattern":"^https://www.nandland.com" }, diff --git a/.github/workflows/build_devtool.yml b/.github/workflows/build_devtool.yml index 240dce3ce..6263bb692 100644 --- a/.github/workflows/build_devtool.yml +++ b/.github/workflows/build_devtool.yml @@ -28,7 +28,10 @@ jobs: run: tool/gh_actions/devtool/run_devtool_test.sh - name: Build Static Web - run: tool/gh_actions/devtool/build_web.sh + run: tool/gh_actions/devtool/install_devtools.sh + + - name: Test DevTools Installation + run: tool/gh_actions/devtool/test_devtools_install.sh extension/devtools - name: Create artifact branch and commit run: | diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 673f550d3..f2ed2cf65 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -117,5 +117,8 @@ jobs: run: tool/gh_actions/devtool/run_devtool_test.sh - name: Build Static Web - run: tool/gh_actions/devtool/build_web.sh + run: tool/gh_actions/devtool/install_devtools.sh + + - name: Test DevTools Installation + run: tool/gh_actions/devtool/test_devtools_install.sh extension/devtools diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ef3211c..bb1970696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## Next release -- Improved generated SystemVerilog to collapse a variety of intermediate `LogicArray`s and net buses (e.g. from bit-blasting, aggregate connections, `assignSubset`) into inline concatenations on their consuming connections, eliminating unnecessary intermediate declarations, `assign`s, and `net_connect`s when it is safe to do so. +- Improved generated SystemVerilog to collapse contiguous partial array and range assignments into packed slice assignments when safe (). +- Added configurable explicit or implicit object and data types for generated SystemVerilog ports, defaulting to `input wire logic`, `output var logic`, and `inout wire logic` (). +- Improved `Logic.getRange` and `slice` on filled `Const`s to return direct constants instead of constructing `BusSubset` modules (). +- Improved generated SystemVerilog for swizzles to compact adjacent bit selections into legal slice expressions (). +- Improved generated SystemVerilog to collapse a variety of intermediate `LogicArray`s and net buses (e.g. from bit-blasting, aggregate connections, `assignSubset`) into inline concatenations on their consuming connections, eliminating unnecessary intermediate declarations, `assign`s, and `net_connect`s when it is safe to do so (). ## 0.6.9 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb333f2a0..6bb9116ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ To run the complete ROHD test suite for development, you need to install [Icarus #### On your own system -[Visual Studio Code (VSCode)](https://code.visualstudio.com/) is a great IDE for development. You can find installation instructions for VSCode here: +[Visual Studio Code (VSCode)](https://code.visualstudio.com/) is a great IDE for development. You can find installation instructions for VSCode here: The Dart extension extends VSCode with support for the Dart programming language and provides tools for effectively editing, refactoring and running. Check out the detailed information: diff --git a/analysis_options.yaml b/analysis_options.yaml index 2b2098177..4cce8f7b5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,7 +8,9 @@ analyzer: strict-raw-types: true exclude: - doc/tutorials/chapter_9/rohd_vf_example + - packages/rohd_hierarchy - rohd_devtools_extension + - rohd_extension # keep up to date, matching https://dart.dev/tools/linter-rules/all # some lints are not yet available, so disabled and marked with [not currently recognized] diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 000000000..f17ff0ca6 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - rohd: true \ No newline at end of file diff --git a/doc/user_guide/_docs/A21-generation.md b/doc/user_guide/_docs/A21-generation.md index 00d3d25bb..5c172eeb9 100644 --- a/doc/user_guide/_docs/A21-generation.md +++ b/doc/user_guide/_docs/A21-generation.md @@ -28,6 +28,21 @@ void main() async { The `generateSynth` function will return a `String` with the SystemVerilog `module` definitions for the top-level it is called on, as well as any sub-modules (recursively). You can dump the entire contents to a file and use it anywhere you would any other SystemVerilog. +## Controlling port types + +Generated ports have explicit object and data types by default: inputs are `input wire logic`, outputs are `output var logic`, and inouts are `inout wire logic`. Use a `SystemVerilogSynthesizerConfiguration` to omit either category and rely on SystemVerilog's implicit types: + +```dart +final generatedSv = myModule.generateSynth( + configuration: const SystemVerilogSynthesizerConfiguration( + portObjectType: SystemVerilogPortType.implicit, + portDataType: SystemVerilogPortType.implicit, + ), +); +``` + +The same configuration can be passed directly to `SystemVerilogSynthesizer` when using `SynthBuilder`. + ## Controlling naming ### Modules diff --git a/lib/src/fst/fst_writer.dart b/lib/src/fst/fst_writer.dart index 11849a5d7..fc05d1c83 100644 --- a/lib/src/fst/fst_writer.dart +++ b/lib/src/fst/fst_writer.dart @@ -4,8 +4,8 @@ // fst_writer.dart // Pure Dart implementation of FST (Fast Signal Trace) binary writer. // -// Writes valid FST files compatible with GTKWave, Surfer, wellen reader. -// Reference: fst-reader 0.14.2 (io.rs, types.rs) and fstapi.c from GTKWave. +// Writes FST files compatible with GTKWave, Surfer, and wellen/fst-reader. +// Implements the public FST binary format in pure Dart. // // 2026 February // Author: Desmond Kirkpatrick diff --git a/lib/src/module.dart b/lib/src/module.dart index ffeff9fc8..a1cb8ec5c 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -1137,7 +1137,12 @@ abstract class Module { /// /// Currently returns one long file in SystemVerilog, but in the future /// may have other output formats, languages, files, etc. - String generateSynth() { + /// + /// The [configuration] controls options specific to SystemVerilog output. + String generateSynth({ + SystemVerilogSynthesizerConfiguration configuration = + const SystemVerilogSynthesizerConfiguration(), + }) { if (!_hasBuilt) { throw ModuleNotBuiltException(this); } @@ -1151,9 +1156,10 @@ abstract class Module { '''; return synthHeader + - SynthBuilder(this, SystemVerilogSynthesizer()) - .getSynthFileContents() - .join('\n\n////////////////////\n\n'); + SynthBuilder( + this, + SystemVerilogSynthesizer(configuration: configuration), + ).getSynthFileContents().join('\n\n////////////////////\n\n'); } } diff --git a/lib/src/modules/bus.dart b/lib/src/modules/bus.dart index 918c53dcc..3a797fbf4 100644 --- a/lib/src/modules/bus.dart +++ b/lib/src/modules/bus.dart @@ -183,6 +183,11 @@ class BusSubset extends Module with InlineSystemVerilog { class Swizzle extends Module with InlineSystemVerilog { final String _out = Naming.unpreferredName('swizzled'); + /// A regular expression that will have matches if an expression is a single + /// bit select of a signal or packed array element. + static final RegExp _singleBitSelectRegex = + RegExp(r'^\(?([A-Za-z_][A-Za-z0-9_$]*(?:\[\d+\])*)\[(\d+)\]\)?$'); + /// The output port containing concatenated signals. late final Logic out; @@ -261,25 +266,25 @@ class Swizzle extends Module with InlineSystemVerilog { // Calculate all width descriptions upfront to determine alignment final validInputs = _swizzleInputs.reversed.where((e) => e.width > 0).toList(); + final operands = _collapseContiguousBitSelects(validInputs, inputs); // If there's only one element, no need for width descriptions - if (validInputs.length == 1) { - final inName = inputs[validInputs.first.name]!; - return inName; + if (operands.length == 1) { + return operands.first.expression; } final widthDescriptions = <({int upper, int? lower})>[]; var upperIndex = out.width - 1; // First pass: calculate all width descriptions - for (final e in validInputs) { - if (e.width > 1) { - final lowerIndex = upperIndex - e.width + 1; + for (final operand in operands) { + if (operand.width > 1) { + final lowerIndex = upperIndex - operand.width + 1; widthDescriptions.add((upper: upperIndex, lower: lowerIndex)); } else { widthDescriptions.add((upper: upperIndex, lower: null)); } - upperIndex -= e.width; + upperIndex -= operand.width; } // Find maximum width for alignment @@ -301,8 +306,7 @@ class Swizzle extends Module with InlineSystemVerilog { final inputLines = []; var descIndex = 0; - for (final e in validInputs) { - final inName = inputs[e.name]!; + for (final operand in operands) { final desc = widthDescriptions[descIndex++]; String alignedDesc; @@ -317,10 +321,10 @@ class Swizzle extends Module with InlineSystemVerilog { alignedDesc = desc.upper.toString().padLeft(totalWidth); } - upperIndex -= e.width; + upperIndex -= operand.width; final maybeComma = upperIndex >= 0 ? ',' : ' '; // space at end for alignment - inputLines.add('$inName$maybeComma /* $alignedDesc */'); + inputLines.add('${operand.expression}$maybeComma /* $alignedDesc */'); } return ''' @@ -328,4 +332,97 @@ class Swizzle extends Module with InlineSystemVerilog { ${inputLines.join('\n')} }'''; } + + /// Rewrites runs of adjacent descending single-bit selects from the same + /// packed signal into wider SystemVerilog slices. + /// + /// For example, `a[7], a[6], a[5]` becomes `a[7:5]`, and + /// `a[0][1], a[0][0]` becomes `a[0][1:0]`. Ascending runs are intentionally + /// left expanded because SystemVerilog slices cannot reverse bit order with + /// `lower:upper` syntax. + List<({String expression, int width})> _collapseContiguousBitSelects( + List validInputs, + Map inputs, + ) { + final operands = <({String expression, int width})>[]; + + var index = 0; + while (index < validInputs.length) { + final input = validInputs[index]; + final expression = inputs[input.name]!; + final selectedBit = _singleBitSelect(input, expression); + if (selectedBit == null) { + operands.add((expression: expression, width: input.width)); + index++; + continue; + } + + var lowerIndex = selectedBit.index; + var endIndex = index + 1; + while (endIndex < validInputs.length) { + final nextInput = validInputs[endIndex]; + final nextExpression = inputs[nextInput.name]!; + final nextSelectedBit = _singleBitSelect(nextInput, nextExpression); + if (nextSelectedBit == null || + nextSelectedBit.source != selectedBit.source || + nextSelectedBit.index != lowerIndex - 1) { + break; + } + + lowerIndex = nextSelectedBit.index; + endIndex++; + } + + if (endIndex == index + 1) { + operands.add((expression: expression, width: input.width)); + } else { + operands.add(( + expression: '${selectedBit.source}[${selectedBit.index}:$lowerIndex]', + width: endIndex - index, + )); + } + index = endIndex; + } + + return operands; + } + + /// Parses [expression] as a single-bit select of a packed signal when it is + /// safe to participate in slice collapsing. + /// + /// Returns `null` for multi-bit inputs, non-select expressions, or selects + /// sourced from unpacked arrays. + ({String source, int index})? _singleBitSelect( + Logic input, + String expression, + ) { + if (input.width != 1 || _hasUnpackedArraySource(input.srcConnection)) { + return null; + } + + final match = _singleBitSelectRegex.firstMatch(expression); + if (match == null) { + return null; + } + + return (source: match.group(1)!, index: int.parse(match.group(2)!)); + } + + /// Walks up [logic]'s containing structures to detect unpacked arrays. + /// + /// SystemVerilog packed slices are not interchangeable with unpacked array + /// indexing, so any unpacked array source disables bit-select collapsing. + bool _hasUnpackedArraySource(Logic? logic) { + var current = logic; + while (current?.parentStructure != null) { + final parentStructure = current!.parentStructure!; + if (parentStructure is LogicArray && + parentStructure.numUnpackedDimensions > 0) { + return true; + } + current = parentStructure; + } + + return false; + } } diff --git a/lib/src/signals/logic.dart b/lib/src/signals/logic.dart index 4c5f99e5e..a412f0943 100644 --- a/lib/src/signals/logic.dart +++ b/lib/src/signals/logic.dart @@ -768,10 +768,31 @@ class Logic { return this; } + final constantFillValue = _constantFillValueOf(this); + if (constantFillValue != null) { + return Const( + LogicValue.filled( + (modifiedEndIndex - modifiedStartIndex).abs() + 1, + constantFillValue, + ), + ); + } + // Create a new bus subset return BusSubset(this, modifiedStartIndex, modifiedEndIndex).subset; } + static LogicValue? _constantFillValueOf(Logic logic) { + if (logic is! Const || logic.width == 0) { + return null; + } + + final fillValue = logic.value[0]; + return logic.value == LogicValue.filled(logic.width, fillValue) + ? fillValue + : null; + } + /// Returns a version of this [Logic] with the bit order reversed. late final Logic reversed = (isNet ? LogicNet.new : Logic.new)( name: 'reversed_$name', naming: Naming.unnamed, width: width) diff --git a/lib/src/synthesizers/systemverilog/systemverilog.dart b/lib/src/synthesizers/systemverilog/systemverilog.dart index 281b05df9..6990cbe2a 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog.dart @@ -1,5 +1,6 @@ -// Copyright (C) 2021-2024 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause export 'systemverilog_mixins.dart'; export 'systemverilog_synthesizer.dart'; +export 'systemverilog_synthesizer_configuration.dart'; diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart b/lib/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart index 30767b27a..18ff4caed 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart @@ -27,6 +27,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { @override void process() { + _inlinePackedRangesIntoSubmoduleInputs(); _collapseAggregateConnections(); _collapseWholeNetBuses(); _forwardPassthroughElementsIntoInlineables(); @@ -35,6 +36,173 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { _replaceInOutConnectionInlineableModules(); } + /// Inlines a fully covered packed bus into its sole submodule input. + /// + /// Each driver must cover the next contiguous destination range and supply + /// its entire source. Constant-backed intermediates are resolved to their + /// literal before the sources are joined into an inline concatenation. + /// + /// This remains SystemVerilog-specific because the replacement is an inline + /// [Swizzle] expression. Backend-neutral range discovery and composition are + /// handled by the base [SynthModuleDefinition] before [process] is called. + void _inlinePackedRangesIntoSubmoduleInputs() { + final inputUses = >{}; + final allMappedSignals = {}; + final outputOrInOutMappedSignals = {}; + for (final instantiation in subModuleInstantiations) { + instantiation as SystemVerilogSynthSubModuleInstantiation; + for (final entry in instantiation.inputMapping.entries) { + if (instantiation.module is! InlineSystemVerilog) { + allMappedSignals.add(entry.value.resolved); + } + inputUses.putIfAbsent(entry.value.resolved, () => []).add(( + instantiation: instantiation, + portName: entry.key, + )); + } + for (final signal in [ + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + ]) { + if (instantiation.module is! InlineSystemVerilog) { + allMappedSignals.add(signal.resolved); + outputOrInOutMappedSignals.add(signal.resolved); + } + } + } + + final assignmentsByDestination = >{}; + final assignmentsBySource = >{}; + for (final assignment in assignments) { + assignmentsByDestination + .putIfAbsent(assignment.dst.resolved, () => []) + .add(assignment); + assignmentsBySource + .putIfAbsent(assignment.src.resolved, () => []) + .add(assignment); + } + + final removedAssignments = {}; + final removedSignals = {}; + for (final entry in inputUses.entries) { + final bus = entry.key; + final use = entry.value.singleOrNull; + if (use == null || + bus.isArray || + bus.isNet || + bus.isConstant || + bus.logics.any( + (logic) => logic is LogicStructure || logic.parentStructure != null, + ) || + bus.isPort(module) || + outputOrInOutMappedSignals.contains(bus) || + !bus.isClearable || + !internalSignals.contains(bus) || + use.instantiation.module is InlineSystemVerilog || + (use.instantiation.module is SystemVerilog && + (use.instantiation.module as SystemVerilog) + .expressionlessInputs + .contains(use.portName))) { + continue; + } + + final drivers = List.of( + assignmentsByDestination[bus] ?? const [], + )..sort((a, b) => + _packedDestinationLower(a).compareTo(_packedDestinationLower(b))); + if (drivers.isEmpty || (assignmentsBySource[bus]?.isNotEmpty ?? false)) { + continue; + } + + var nextDestinationBit = 0; + var canInline = true; + final sources = []; + final constantDrivers = {}; + final constantIntermediates = {}; + for (final driver in drivers) { + final lower = _packedDestinationLower(driver); + final upper = _packedDestinationUpper(driver); + if (driver is! PartialSynthAssignment || + lower != nextDestinationBit || + upper >= bus.width || + (driver is RangeSynthAssignment && + (driver.srcLowerIndex != 0 || + driver.srcUpperIndex != driver.src.width - 1))) { + canInline = false; + break; + } + + var source = driver.src.resolved; + if (source.isArray || source.width != upper - lower + 1) { + canInline = false; + break; + } + + final sourceDrivers = + assignmentsByDestination[source] ?? const []; + final sourceConsumers = + assignmentsBySource[source] ?? const []; + final removableConstantIntermediate = sourceDrivers.length == 1 && + sourceConsumers.length == 1 && + sourceConsumers.single == driver && + sourceDrivers.single is! PartialSynthAssignment && + sourceDrivers.single.src.resolved.isConstant && + !source.hasPreservedName && + !allMappedSignals.contains(source) && + internalSignals.contains(source); + if (removableConstantIntermediate) { + constantDrivers.add(sourceDrivers.single); + constantIntermediates.add(source); + source = sourceDrivers.single.src.resolved; + } + + sources.add(source); + nextDestinationBit = upper + 1; + } + + if (!canInline || nextDestinationBit != bus.width) { + continue; + } + + _addSwizzleConnect(bus, sources); + removedAssignments + ..addAll(drivers) + ..addAll(constantDrivers); + removedSignals + ..add(bus) + ..addAll(constantIntermediates); + } + + if (removedAssignments.isNotEmpty) { + final retainedAssignments = [ + for (final assignment in assignments) + if (!removedAssignments.contains(assignment)) assignment, + ]; + assignments + ..clear() + ..addAll(retainedAssignments); + for (final signal in removedSignals) { + signal.clearDeclaration(); + internalSignals.remove(signal); + } + } + } + + /// Returns the lower destination bit selected by [assignment]. + int _packedDestinationLower(SynthAssignment assignment) => + assignment is PartialSynthAssignment ? assignment.dstLowerIndex : 0; + + /// Returns the upper destination bit selected by [assignment]. + int _packedDestinationUpper(SynthAssignment assignment) => + assignment is PartialSynthAssignment + ? assignment.dstUpperIndex + : assignment.dst.width - 1; + @override SynthSubModuleInstantiation createSubModuleInstantiation(Module m) => SystemVerilogSynthSubModuleInstantiation(m); @@ -120,6 +288,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // sources that are tied through a pass-through net bus rather than via a // direct assignment (see [_traceNetSubsetSource]). final netSubsets = _netSubsetLookups(); + final logicSubsets = _logicSubsetLookups(); // Count how many times each array [SynthLogic] is used "as a whole", // along with where (a submodule port mapping is the only use we can @@ -172,6 +341,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // sole appearance is its single connecting assignment; if it is read // anywhere else (e.g. by a reduction), the aggregate must stay intact. final elementAssignments = >{}; + final assignmentsBySignal = _assignmentsBySignal(); final elementUseCount = {}; void noteElementUse(SynthLogic? synthLogic) { if (synthLogic is SynthLogicArrayElement) { @@ -198,6 +368,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { } } + final removedAssignments = {}; for (final aggEntry in aggregatePortUse.entries) { final agg = aggEntry.key; final use = aggEntry.value; @@ -238,6 +409,34 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { .map((e) => e?.resolved) .toList(); + final aggregateLogic = agg.logics.singleOrNull; + final isPackedBitArray = aggregateLogic is LogicArray && + aggregateLogic.dimensions.length == 1 && + aggregateLogic.elementWidth == 1 && + aggregateLogic.numUnpackedDimensions == 0; + final packedSubsetSource = isPackedBitArray + ? _packedLogicSubsetSource( + elementLogics, + logicSubsets, + expectedWidth: agg.width, + ) + : null; + if (packedSubsetSource != null && + use.instantiation.inputMapping.containsKey(use.portName)) { + use.instantiation.setInputMapping( + use.portName, + packedSubsetSource.source, + replace: true, + ); + for (final subset in packedSubsetSource.subsets) { + subset.clearInstantiation(); + } + agg.clearDeclaration(); + internalSignals.remove(agg); + changed = true; + continue; + } + final elementSources = []; var allElementsSingleSourced = true; @@ -260,6 +459,10 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { connectingAssignments.length == 1 && (elementUseCount[element] ?? 0) == 1) { final assignment = connectingAssignments.single; + if (assignment is PartialSynthAssignment) { + allElementsSingleSourced = false; + break; + } final source = assignment.src.resolved == element ? assignment.dst.resolved : assignment.src.resolved; @@ -278,6 +481,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { final traced = _traceNetSubsetSource( element, netSubsets, + assignmentsBySignal, elementUseCount, elementAssignments, tracedSubsets, @@ -296,7 +500,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // A traced pass-through bus may only be dropped if it is fully consumed // by this collapse: every net [BusSubset] referencing it must have been - // traced, and it must be a clearable/renameable internal non-port. + // traced, and it must be a clearable internal non-port. if (!_tracedBusesFullyConsumed( tracedBuses, tracedSubsets, netSubsets)) { continue; @@ -331,10 +535,11 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // Remove the now-inlined element assignments and clear the aggregate // declaration. - final elementSet = elementLogics.nonNulls.toSet(); - assignments.removeWhere((assignment) => - elementSet.contains(assignment.src.resolved) || - elementSet.contains(assignment.dst.resolved)); + for (final element in elementLogics.nonNulls) { + removedAssignments.addAll( + elementAssignments[element] ?? const [], + ); + } agg.clearDeclaration(); internalSignals.remove(agg); @@ -350,7 +555,72 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { changed = true; } + assignments.removeWhere(removedAssignments.contains); + } + } + + /// Indexes non-net [BusSubset] views by their resolved subset output. + Map> _logicSubsetLookups() { + final bySubset = >{}; + for (final instantiation in subModuleInstantiations) { + instantiation as SystemVerilogSynthSubModuleInstantiation; + final subsetModule = instantiation.module; + if (subsetModule is! BusSubset || subsetModule.original.isNet) { + continue; + } + final original = + instantiation.inputMapping[subsetModule.original.name]?.resolved; + final subset = + instantiation.outputMapping[subsetModule.subset.name]?.resolved; + if (original == null || subset == null) { + continue; + } + final view = ( + inst: instantiation, + original: original, + subset: subset, + start: subsetModule.startIndex, + end: subsetModule.endIndex, + ); + bySubset.putIfAbsent(subset, () => []).add(view); } + return bySubset; + } + + /// Reconstructs one fully covered packed source from ordered element subset + /// mappings, returning the source and helper instantiations to clear. + ({ + SynthLogic source, + Set subsets, + })? _packedLogicSubsetSource( + List elements, + Map> subsetsByOutput, { + required int expectedWidth, + }) { + SynthLogic? source; + var nextBit = 0; + final subsets = {}; + for (final element in elements) { + if (element == null) { + return null; + } + final view = subsetsByOutput[element]?.singleOrNull; + if (view == null || + view.start != nextBit || + view.end != nextBit + element.width - 1 || + (source != null && source != view.original)) { + return null; + } + source ??= view.original; + subsets.add(view.inst); + nextBit += element.width; + } + if (source == null || + source.width != expectedWidth || + nextBit != expectedWidth) { + return null; + } + return (source: source, subsets: subsets); } /// Builds resolved lookups over every net [BusSubset] instantiation: a list @@ -410,6 +680,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { Map> byOriginal, Map> bySubset, }) netSubsets, + Map> assignmentsBySignal, Map elementUseCount, Map> elementAssignments, Set tracedSubsets, @@ -429,9 +700,9 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { final bit = elementView.start; final bus = elementView.original; - // The bus must be a clearable/renameable internal non-port pass-through. + // The bus must be a clearable internal non-port pass-through. if (bus.declarationCleared || - !bus.isClearableOrRenameable || + !bus.isClearable || bus.isPort(module) || !internalSignals.contains(bus)) { return null; @@ -447,7 +718,16 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { final siblingView = siblings.single; final sourceNet = siblingView.subset; - if (sourceNet == element) { + if (sourceNet == element || + (!_isPort(sourceNet) && + _hasSubmoduleUseOutsideThroughAssignment( + sourceNet, + { + elementView.inst, + siblingView.inst, + }, + assignmentsBySignal, + ))) { return null; } @@ -510,6 +790,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { changed = false; final netSubsets = _netSubsetLookups(); + final assignmentsBySignal = _assignmentsBySignal(); // Count whole uses of each candidate bus and remember the single // submodule port use we can inline into. Uses as the [original] of a @@ -576,11 +857,11 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { continue; } - // The bus must be a clearable/renameable internal non-port net. + // The bus must be a clearable internal non-port net. if (bus.declarationCleared || !bus.isNet || bus.isArray || - !bus.isClearableOrRenameable || + !bus.isClearable || bus.isPort(module) || !internalSignals.contains(bus)) { continue; @@ -608,7 +889,8 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { } // Every bit of the bus must be tiled exactly once by a single-bit - // [BusSubset] definer, covering [0, width). + // [BusSubset] definer covering [0, width). A slice that also feeds + // real logic is a consumer too, so it cannot be removed here. final definers = netSubsets.byOriginal[bus]!; if (definers.any((v) => v.start != v.end)) { continue; @@ -637,6 +919,16 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { if (!tiledExactly) { continue; } + final definerInstantiations = {for (final view in definers) view.inst}; + if (definers.any((view) => + !_isPort(view.subset) && + _hasSubmoduleUseOutsideThroughAssignment( + view.subset, + definerInstantiations, + assignmentsBySignal, + ))) { + continue; + } // Build the per-bit source nets, LSB-first. final elementSources = [ @@ -663,6 +955,81 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { } } + Map> _assignmentsBySignal() { + final assignmentsBySignal = >{}; + for (final assignment in assignments) { + for (final signal in [assignment.src.resolved, assignment.dst.resolved]) { + assignmentsBySignal.putIfAbsent(signal, () => []).add(assignment); + } + } + return assignmentsBySignal; + } + + bool _isPort(SynthLogic signal) => + signal.resolved.isPort(module) || + signal.resolved.isStructPortElement(module); + + bool _hasSubmoduleUseOutside( + SynthLogic signal, + Set ignoredInstantiations, + ) { + for (final instantiation in subModuleInstantiations) { + instantiation as SystemVerilogSynthSubModuleInstantiation; + if (ignoredInstantiations.contains(instantiation)) { + continue; + } + if (instantiation.module is BusSubset) { + continue; + } + final mappedSignals = [ + ...instantiation.inputMapping.values, + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + ]; + if (mappedSignals.any( + (mappedSignal) => _signalsShareReferenceBase( + mappedSignal.resolved, + signal.resolved, + ), + )) { + return true; + } + } + return false; + } + + bool _hasSubmoduleUseOutsideThroughAssignment( + SynthLogic signal, + Set ignoredInstantiations, + Map> assignmentsBySignal, + ) { + if (_hasSubmoduleUseOutside(signal, ignoredInstantiations)) { + return true; + } + + for (final assignment + in assignmentsBySignal[signal.resolved] ?? const []) { + final other = assignment.src.resolved == signal.resolved + ? assignment.dst.resolved + : assignment.src.resolved; + if (!_isPort(other) && + _hasSubmoduleUseOutside(other, ignoredInstantiations)) { + return true; + } + } + return false; + } + + bool _signalsShareReferenceBase(SynthLogic a, SynthLogic b) => + a == b || _referenceBase(a) == b || _referenceBase(b) == a; + + /// Returns the packed object referenced by [signal] for usage comparisons. + SynthLogic _referenceBase(SynthLogic signal) => switch (signal) { + SynthLogicArrayElement() => signal.parentArray.resolved, + SynthLogicPackedBitReference() => signal.packedBase.resolved, + _ => signal.resolved, + }; + /// Forwards the per-element sources of a pass-through [LogicArray] directly /// into the single inlineable submodule (e.g. a [Swizzle]) that consumes /// those elements, then drops the now-dead array. @@ -827,6 +1194,7 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // would stay declared and its remaining (e.g. differently-driven or // undriven `x`/`z`) elements could change behavior. final droppedArrays = []; + final removedAssignments = {}; candidatesByArray.forEach((parentArray, arrayCandidates) { if (parentArray.declarationCleared || !parentArray.isClearable || @@ -850,7 +1218,6 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { // Rewire each element's consuming port straight to the element's // source, and remove the now-redundant element assignment. - final removedAssignments = {}; for (final element in arrayCandidates) { final read = inlineablePortReads[element]!.single; final assignment = elementAssignments[element]!.single; @@ -870,11 +1237,11 @@ class SystemVerilogSynthModuleDefinition extends SynthModuleDefinition { (element as SynthLogicArrayElement).clearDeclaration(); } - assignments.removeWhere(removedAssignments.contains); droppedArrays.add(parentArray); changed = true; }); + assignments.removeWhere(removedAssignments.contains); _dropEmptiedArrays(droppedArrays); } } @@ -1073,6 +1440,16 @@ typedef _NetSubsetView = ({ int end, }); +/// A resolved view of a non-net [BusSubset] used to reconstruct a packed bus +/// from an array input's element mappings. +typedef _LogicSubsetView = ({ + SystemVerilogSynthSubModuleInstantiation inst, + SynthLogic original, + SynthLogic subset, + int start, + int end, +}); + /// A special [Module] for connecting or assigning two SystemVerilog nets /// together bidirectionally. /// diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesis_result.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesis_result.dart index 72471eef1..b86fc4d34 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesis_result.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesis_result.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // systemverilog_synthesis_result.dart @@ -58,6 +58,9 @@ class SystemVerilogCustomDefinitionSynthesisResult extends SynthesisResult { /// A [SynthesisResult] representing a conversion of a [Module] to /// SystemVerilog. class SystemVerilogSynthesisResult extends SynthesisResult { + /// Configuration controlling generated SystemVerilog. + final SystemVerilogSynthesizerConfiguration configuration; + /// A cached copy of the generated ports. late final String _portsString; @@ -75,8 +78,11 @@ class SystemVerilogSynthesisResult extends SynthesisResult { _synthModuleDefinition.supportingModules; /// Creates a new [SystemVerilogSynthesisResult] for the given [module]. - SystemVerilogSynthesisResult(super.module, super.getInstanceTypeOfModule) - : _synthModuleDefinition = SystemVerilogSynthModuleDefinition(module) { + SystemVerilogSynthesisResult( + super.module, + super.getInstanceTypeOfModule, { + this.configuration = const SystemVerilogSynthesizerConfiguration(), + }) : _synthModuleDefinition = SystemVerilogSynthModuleDefinition(module) { _portsString = _verilogPorts(); _moduleContentsString = _verilogModuleContents(getInstanceTypeOfModule); _parameterString = _verilogParameters(module); @@ -111,7 +117,7 @@ class SystemVerilogSynthesisResult extends SynthesisResult { Iterable _verilogInputs() => _synthModuleDefinition.inputs.map((sig) { assert(module.tryInput(sig.name) != null, 'Named input ${sig.name} not found in module ${module.name}.'); - return 'input ${sig.definitionType()} ${sig.definitionName()}'; + return _verilogPort('input', 'wire', sig); }); /// Representation of all output port declarations in generated SV. @@ -119,16 +125,26 @@ class SystemVerilogSynthesisResult extends SynthesisResult { _synthModuleDefinition.outputs.map((sig) { assert(module.tryOutput(sig.name) != null, 'Named output ${sig.name} not found in module ${module.name}.'); - return 'output ${sig.definitionType()} ${sig.definitionName()}'; + return _verilogPort('output', 'var', sig); }); /// Representation of all inout port declarations in generated SV. Iterable _verilogInOuts() => _synthModuleDefinition.inOuts.map((sig) { assert(module.tryInOut(sig.name) != null, 'Named inOut ${sig.name} not found in module ${module.name}.'); - return 'inout ${sig.definitionType()} ${sig.definitionName()}'; + return _verilogPort('inout', 'wire', sig); }); + /// Representation of a port declaration in generated SV. + String _verilogPort(String direction, String objectType, SynthLogic sig) => [ + direction, + if (configuration.portObjectType == SystemVerilogPortType.explicit) + objectType, + if (configuration.portDataType == SystemVerilogPortType.explicit) + 'logic', + sig.definitionName(), + ].join(' '); + /// Representation of all internal net declarations in generated SV. String _verilogInternalSignals() { final declarations = []; @@ -143,21 +159,37 @@ class SystemVerilogSynthesisResult extends SynthesisResult { /// Representation of all assignments in generated SV. String _verilogAssignments() { final assignmentLines = []; + String rangeString(int upperIndex, int lowerIndex) => + upperIndex == lowerIndex + ? '[$upperIndex]' + : '[$upperIndex:$lowerIndex]'; + for (final assignment in _synthModuleDefinition.assignments) { assert( !(assignment.src.isNet && assignment.dst.isNet), 'Net connections should have been implemented as' ' bidirectional net connections.'); - var sliceString = ''; - if (assignment is PartialSynthAssignment && assignment.width > 1) { - sliceString = assignment.dstUpperIndex == assignment.dstLowerIndex - ? '[${assignment.dstUpperIndex}]' - : '[${assignment.dstUpperIndex}:${assignment.dstLowerIndex}]'; + var dstSliceString = ''; + var srcSliceString = ''; + if (assignment is RangeSynthAssignment) { + dstSliceString = rangeString( + assignment.dstUpperIndex, + assignment.dstLowerIndex, + ); + srcSliceString = rangeString( + assignment.srcUpperIndex, + assignment.srcLowerIndex, + ); + } else if (assignment is PartialSynthAssignment && assignment.width > 1) { + dstSliceString = rangeString( + assignment.dstUpperIndex, + assignment.dstLowerIndex, + ); } - assignmentLines.add('assign ${assignment.dst.name}$sliceString' - ' = ${assignment.src.name};'); + assignmentLines.add('assign ${assignment.dst.name}$dstSliceString' + ' = ${assignment.src.name}$srcSliceString;'); } return assignmentLines.join('\n'); } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index 062647ac3..5aea3cf58 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart @@ -15,6 +15,14 @@ import 'package:rohd/src/synthesizers/systemverilog/systemverilog_synthesis_resu /// /// Attempts to maintain signal naming and structure as much as possible. class SystemVerilogSynthesizer extends Synthesizer { + /// Configuration controlling generated SystemVerilog. + final SystemVerilogSynthesizerConfiguration configuration; + + /// Creates a SystemVerilog synthesizer with the specified [configuration]. + SystemVerilogSynthesizer({ + this.configuration = const SystemVerilogSynthesizerConfiguration(), + }); + @override bool generatesDefinition(Module module) => // ignore: deprecated_member_use_from_same_package @@ -147,6 +155,10 @@ class SystemVerilogSynthesizer extends Synthesizer { module.generatedDefinitionType == DefinitionGenerationType.custom ? SystemVerilogCustomDefinitionSynthesisResult( module, getInstanceTypeOfModule) - : SystemVerilogSynthesisResult(module, getInstanceTypeOfModule); + : SystemVerilogSynthesisResult( + module, + getInstanceTypeOfModule, + configuration: configuration, + ); } } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer_configuration.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer_configuration.dart new file mode 100644 index 000000000..cea6493eb --- /dev/null +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer_configuration.dart @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// systemverilog_synthesizer_configuration.dart +// Configuration for SystemVerilog synthesis. +// +// 2026 July 10 +// Author: Max Korbel + +/// Controls whether a type is included in a SystemVerilog port declaration. +enum SystemVerilogPortType { + /// The type is included in the port declaration. + explicit, + + /// The type is inferred according to SystemVerilog's defaults. + implicit, +} + +/// Configuration for SystemVerilog synthesis. +class SystemVerilogSynthesizerConfiguration { + /// Whether port object types, such as `wire` and `var`, are explicit. + final SystemVerilogPortType portObjectType; + + /// Whether port data types, such as `logic`, are explicit. + final SystemVerilogPortType portDataType; + + /// Creates a new configuration for SystemVerilog synthesis. + const SystemVerilogSynthesizerConfiguration({ + this.portObjectType = SystemVerilogPortType.explicit, + this.portDataType = SystemVerilogPortType.explicit, + }); +} diff --git a/lib/src/synthesizers/utilities/synth_assignment.dart b/lib/src/synthesizers/utilities/synth_assignment.dart index 945558eab..aaafda601 100644 --- a/lib/src/synthesizers/utilities/synth_assignment.dart +++ b/lib/src/synthesizers/utilities/synth_assignment.dart @@ -74,3 +74,32 @@ class PartialSynthAssignment extends SynthAssignment { @override String toString() => '$dst[$dstUpperIndex:$dstLowerIndex] <= $src'; } + +/// Represents an assignment from a partial source to a partial destination. +class RangeSynthAssignment extends PartialSynthAssignment { + /// The upper index of the source. + int srcUpperIndex; + + /// The lower index of the source. + int srcLowerIndex; + + /// The width of the source and destination ranges. + @override + int get width => dstUpperIndex - dstLowerIndex + 1; + + @override + bool _checkWidths() => width == srcUpperIndex - srcLowerIndex + 1; + + /// Constructs a representation of a range-to-range assignment. + RangeSynthAssignment(super._src, super._dst, + {required this.srcUpperIndex, + required this.srcLowerIndex, + required super.dstUpperIndex, + required super.dstLowerIndex}) + : assert(srcLowerIndex >= 0, 'Invalid source lower index'), + assert(srcUpperIndex < _src.width, 'Invalid source upper index'); + + @override + String toString() => '$dst[$dstUpperIndex:$dstLowerIndex] <= ' + '$src[$srcUpperIndex:$srcLowerIndex]'; +} diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index d2423b4b7..d29cc84f3 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -77,6 +77,11 @@ class SynthLogic { /// Indicates that this has a reserved name. bool get isReserved => _reservedLogic != null; + /// Whether this contains a renameable or reserved name that must remain in + /// generated output. + bool get hasPreservedName => + _reservedLogic != null || _renameableLogic != null; + /// The [Logic] whose name is reserved, if there is one. Logic? _reservedLogic; @@ -131,15 +136,6 @@ class SynthLogic { /// may be additional conditions that prevent clearing. bool get isClearable => mergeable; - /// Like [isClearable], but additionally permits a *renameable* signal (whose - /// chosen name carries no semantic meaning and would simply be dropped). - /// - /// This is `false` only for [isReserved] (e.g. ports) and constant signals, - /// whose identities must be preserved. It is used by the aggregate-collapse - /// optimizations to eliminate intermediate (possibly named) nets. - bool get isClearableOrRenameable => - _reservedLogic == null && _constLogic == null; - /// The source connections to any [Logic] in this [SynthLogic] which are not /// also contained within this [SynthLogic]. Iterable get srcConnections { @@ -406,6 +402,70 @@ class SynthLogic { } } +/// A non-owning reference to one bit of a packed [SynthLogic]. +/// +/// This exists for port mappings that must render an indexed packed signal, +/// such as `.result(bus[7])`. It has no declaration or independently selected +/// name; [name] is always derived from [packedBase] and [bitIndex]. +class SynthLogicPackedBitReference extends SynthLogic { + /// The packed signal containing the referenced bit. + final SynthLogic packedBase; + + /// The bit selected from [packedBase]. + final int bitIndex; + + /// Creates a reference to [bitIndex] of [packedBase]. + SynthLogicPackedBitReference( + this.packedBase, + this.bitIndex, { + required super.parentSynthModuleDefinition, + }) : assert( + !packedBase.isArray, 'Packed reference base must not be an array.'), + assert(!packedBase.isNet, 'Packed reference base must not be a net.'), + assert( + !packedBase.isConstant, + 'Packed reference base must not be a constant.', + ), + assert(bitIndex >= 0, 'Packed reference index must not be negative.'), + assert( + bitIndex < packedBase.width, + 'Packed reference index must fit within its base.', + ), + super(Logic()); + + @override + bool get needsDeclaration => false; + + @override + bool get mergeable => false; + + @override + bool isPort([Module? module]) => packedBase.resolved.isPort(module); + + @override + bool hasSrcConnectionsPresent() => + packedBase.resolved.hasSrcConnectionsPresent(); + + @override + bool hasDstConnectionsPresent() => + packedBase.resolved.hasDstConnectionsPresent(); + + @override + String get name { + final resolvedBase = packedBase.resolved; + assert( + bitIndex < resolvedBase.width, + 'Packed reference index must fit within its resolved base.', + ); + final reference = '${resolvedBase.name}[$bitIndex]'; + assert( + Sanitizer.isSanitary(resolvedBase.name), + 'Packed reference base should be sanitary, but found $reference.', + ); + return reference; + } +} + /// Represents an element of a [LogicArray]. /// /// Does not fully override or properly implement all characteristics of @@ -467,8 +527,8 @@ class SynthLogicArrayElement extends SynthLogic { @override String get name { - final parentArrayname = parentArray.replacement?.name ?? parentArray.name; - final n = '$parentArrayname[${logic.arrayIndex!}]'; + final parentArrayName = parentArray.replacement?.name ?? parentArray.name; + final n = '$parentArrayName[${logic.arrayIndex!}]'; assert( Sanitizer.isSanitary( n.substring(0, n.contains('[') ? n.indexOf('[') : null), diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index f2b7664dc..4fa0e8d69 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -45,6 +45,77 @@ class _BusSubsetForStructSlice extends BusSubset { Object get instanceNameKey => _destination; } +/// A packed range of a base [SynthLogic], inclusive of [lower] and [upper]. +class _SynthRangeRef { + /// The signal whose packed range is referenced. + final SynthLogic base; + + /// The lower index of the range. + final int lower; + + /// The upper index of the range. + final int upper; + + /// The number of bits in this range. + int get width => upper - lower + 1; + + /// Creates a range reference. + const _SynthRangeRef(this.base, this.lower, this.upper) + : assert(lower >= 0, 'Invalid lower index'), + assert(upper >= lower, 'Invalid upper index'); + + /// Creates a range reference if the bounds are valid for [base]. + static _SynthRangeRef? tryCreate(SynthLogic base, int lower, int upper) { + if (lower < 0 || upper < lower || upper >= base.width) { + return null; + } + return _SynthRangeRef(base, lower, upper); + } + + /// Whether [other] is fully contained in this range. + bool contains(_SynthRangeRef other) => + base == other.base && other.lower >= lower && other.upper <= upper; +} + +/// Submodule users indexed both by an exact mapped signal and by the mapped +/// signal's array reference base. +typedef _SubmoduleSignalUseIndex = ({ + Map> exact, + Map> byReferenceBase, +}); + +/// Yields maximal contiguous runs in [sortedItems] according to +/// [continuesRun]. +Iterable<({int start, int end})> _contiguousRuns( + List sortedItems, + bool Function(T previous, T current) continuesRun, +) sync* { + if (sortedItems.isEmpty) { + return; + } + + var start = 0; + for (var index = 1; index < sortedItems.length; index++) { + if (!continuesRun(sortedItems[index - 1], sortedItems[index])) { + yield (start: start, end: index - 1); + start = index; + } + } + yield (start: start, end: sortedItems.length - 1); +} + +/// Groups [assignments] by the key selected by [keyOf]. +Map> _assignmentsBy( + Iterable assignments, + K Function(SynthAssignment assignment) keyOf, +) { + final assignmentsByKey = >{}; + for (final assignment in assignments) { + assignmentsByKey.putIfAbsent(keyOf(assignment), () => []).add(assignment); + } + return assignmentsByKey; +} + /// Represents the definition of a module. @internal class SynthModuleDefinition { @@ -95,8 +166,10 @@ class SynthModuleDefinition { // Weak-name marks do not remove objects from naming. They make likely // collapsed objects claim names after unmarked objects, so in a collision // the unmarked object keeps the basename and the marked object gets a suffix. + /// Submodules that should claim names after emitted objects. final Set _weakNameClaimSubmodules = {}; + /// Signals that should claim names after emitted objects. final Set _weakNameClaimSignals = {}; /// Indicates that [m] is a submodule used within this definition. @@ -537,10 +610,18 @@ class SynthModuleDefinition { // The order of these is important! _collapseArrays(); + _collapseSimpleRangeAssignments(); + _collapseWideArrayElementRangeSources(); + _collapseChainedRangeAssignments(); + _collapseGeneratedSubsetSwizzleRangeAssignments(); + _collapseConstantBackedRangeIntermediates(); _collapseAssignments(); _assignSubmodulePortMapping(); _pruneUnused(); + _collapseConstantBackedRangeIntermediates(); + _pruneUnused(); + _pruneClearedSubsetConstantSources(); // Naming has two base-owned phases: mark likely-collapsed objects as weak // name claimants, then pick names. After that, synthesizers may @@ -777,6 +858,10 @@ class SynthModuleDefinition { // - assignments that are removable: // - the driver has no driver OR the receiver has no receivers + final assignmentConnectedSubmoduleMappingSignals = + _assignmentConnectedSubmoduleMappingSignals(); + final sharedSubmoduleMappingSignals = _sharedSubmoduleMappingSignals(); + final reducedInternalSignals = []; for (final internalSignal in internalSignals) { // if it's cleared already, just skip it @@ -803,6 +888,13 @@ class SynthModuleDefinition { continue; } + if (assignmentConnectedSubmoduleMappingSignals + .contains(internalSignal.resolved) || + sharedSubmoduleMappingSignals.contains(internalSignal.resolved)) { + reducedInternalSignals.add(internalSignal); + continue; + } + final logics = internalSignal.logics; if (internalSignal.isArray) { @@ -987,12 +1079,15 @@ class SynthModuleDefinition { /// Updates all sub-module instantiations with information about which /// [SynthLogic] should be used for their ports. void _assignSubmodulePortMapping() { + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + for (final submoduleInstantiation in subModuleInstantiations) { for (final inputName in submoduleInstantiation.module.inputs.keys) { final orig = submoduleInstantiation.inputMapping[inputName]!; submoduleInstantiation.setInputMapping( inputName, - orig.replacement ?? orig, + _resolvedSubmoduleInputMapping(orig, assignmentsByDestination), replace: true, ); } @@ -1017,14 +1112,249 @@ class SynthModuleDefinition { } } - /// Picks names of signals and sub-modules. - /// + /// Resolves a submodule input mapping through any replacement and, when the + /// mapped signal is fully driven by a packed scalar assignment, through that + /// driver as well. + SynthLogic _resolvedSubmoduleInputMapping( + SynthLogic mappedSignal, + Map> assignmentsByDestination, + ) { + final resolved = mappedSignal.replacement ?? mappedSignal; + return _fullWidthInputMappingSource(resolved, assignmentsByDestination) ?? + resolved; + } + + /// Returns the source of a single full-width scalar [RangeSynthAssignment] + /// into [mappedSignal], or `null` when the mapped signal must remain named. + SynthLogic? _fullWidthInputMappingSource( + SynthLogic mappedSignal, + Map> assignmentsByDestination, + ) { + final drivers = assignmentsByDestination[mappedSignal.resolved]; + if (drivers == null || drivers.length != 1) { + return null; + } + + final driver = drivers.single; + if (driver is! RangeSynthAssignment) { + return null; + } + + final sourceRange = _assignmentSourceRange(driver); + final destinationRange = _assignmentDestinationRange(driver); + if (destinationRange.base != mappedSignal.resolved || + destinationRange.lower != 0 || + destinationRange.upper != mappedSignal.width - 1 || + sourceRange.width != mappedSignal.width || + sourceRange.lower != 0 || + sourceRange.upper != sourceRange.base.width - 1 || + sourceRange.base.isArray) { + return null; + } + + return sourceRange.base; + } + + /// Finds submodule mapping signals that participate in scalar or packed range + /// assignments across a submodule boundary and therefore must not be pruned + /// before rendering. + Set _assignmentConnectedSubmoduleMappingSignals() { + final submoduleInputMappingReferences = + _submoduleMappingReferences(includeInputs: true, includeOutputs: false); + final submoduleOutputMappingReferences = + _submoduleMappingReferences(includeInputs: false, includeOutputs: true); + final assignmentConnectedSignals = {}; + + var foundInlineDependency = true; + while (foundInlineDependency) { + foundInlineDependency = false; + for (final instantiation in subModuleInstantiations) { + final inlineModule = instantiation.module; + if (inlineModule is! BusSubset || inlineModule.original.isNet) { + continue; + } + final mappedOutputs = [ + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + ]; + if (!mappedOutputs.any( + (mapped) => _isPackedBitArrayElement(mapped.resolved), + )) { + continue; + } + final outputReferences = { + for (final mapped in mappedOutputs) mapped.resolved, + for (final mapped in mappedOutputs) _referenceBase(mapped.resolved), + }; + if (outputReferences + .every((ref) => !submoduleInputMappingReferences.contains(ref))) { + continue; + } + for (final mapped in [ + ...instantiation.inputMapping.values, + ...instantiation.inOutMapping.values, + ]) { + final resolved = mapped.resolved; + foundInlineDependency |= + submoduleInputMappingReferences.add(resolved); + foundInlineDependency |= + submoduleInputMappingReferences.add(_referenceBase(resolved)); + } + } + } + + for (final assignment in assignments) { + final sourceRange = _assignmentSourceRange(assignment); + final destinationRange = _assignmentDestinationRange(assignment); + + final sourceBase = sourceRange.base.resolved; + final destinationBase = destinationRange.base.resolved; + final destinationReferenceBase = _referenceBase(destinationBase); + final sourceIsMappedOutput = + submoduleOutputMappingReferences.contains(sourceBase); + + if (submoduleInputMappingReferences.contains(destinationBase) && + sourceIsMappedOutput) { + assignmentConnectedSignals.add(destinationBase); + } else if (submoduleInputMappingReferences.contains(destinationBase) && + !destinationReferenceBase.isArray && + !sourceBase.isArray) { + assignmentConnectedSignals.add(destinationBase); + } + if (sourceIsMappedOutput) { + assignmentConnectedSignals.add(sourceBase); + } + } + + return assignmentConnectedSignals; + } + + /// Finds signals that are used by multiple active submodule port mappings. + /// After full assignment merging, these shared mapping signals may be the + /// only remaining evidence that a parent-level wire is still required. + Set _sharedSubmoduleMappingSignals() { + final inputMappingUseCounts = {}; + final outputMappingUseCounts = {}; + final inOutMappingUseCounts = {}; + + void addReferences(Map useCounts, SynthLogic signal) { + final resolved = signal.resolved; + for (final reference in {resolved, _referenceBase(resolved)}) { + useCounts.update( + reference, + (count) => count + 1, + ifAbsent: () => 1, + ); + } + } + + for (final submoduleInstantiation in subModuleInstantiations) { + if (!submoduleInstantiation.needsInstantiation || + submoduleInstantiation.module is InlineSystemVerilog) { + continue; + } + + for (final signal in submoduleInstantiation.inputMapping.values) { + addReferences(inputMappingUseCounts, signal); + } + for (final signal in submoduleInstantiation.outputMapping.values) { + addReferences(outputMappingUseCounts, signal); + } + for (final signal in submoduleInstantiation.inOutMapping.values) { + addReferences(inOutMappingUseCounts, signal); + } + } + + final mappingReferences = { + ...inputMappingUseCounts.keys, + ...outputMappingUseCounts.keys, + ...inOutMappingUseCounts.keys, + }; + + final sharedMappingSignals = {}; + for (final reference in mappingReferences) { + final inputUses = inputMappingUseCounts[reference] ?? 0; + final outputUses = outputMappingUseCounts[reference] ?? 0; + final inOutUses = inOutMappingUseCounts[reference] ?? 0; + + if ((inputUses > 0 && outputUses > 0) || + (inOutUses > 0 && (inputUses > 0 || outputUses > 0)) || + inOutUses > 1) { + sharedMappingSignals.add(reference); + } + } + + return sharedMappingSignals; + } + + /// Collects active submodule mapping signals plus their reference bases, so + /// array elements and aggregate mappings compare consistently. + Set _submoduleMappingReferences({ + required bool includeInputs, + required bool includeOutputs, + }) { + final mappedReferences = {}; + + for (final submoduleInstantiation in subModuleInstantiations) { + if (!submoduleInstantiation.needsInstantiation || + submoduleInstantiation.module is InlineSystemVerilog) { + continue; + } + + final mappedSignals = [ + if (includeInputs) ...submoduleInstantiation.inputMapping.values, + if (includeOutputs) ...submoduleInstantiation.outputMapping.values, + ...submoduleInstantiation.inOutMapping.values, + ]; + for (final mappedSignal in mappedSignals) { + final resolved = mappedSignal.resolved; + mappedReferences + ..add(resolved) + ..add(_referenceBase(resolved)); + } + } + + return mappedReferences; + } + + /// Indexes every submodule port mapping by its exact signal and, for array + /// elements, by the direct parent array used for aggregate comparisons. + _SubmoduleSignalUseIndex _submoduleSignalUseIndex() { + final exact = >{}; + final byReferenceBase = >{}; + for (final instantiation in subModuleInstantiations) { + for (final mappedSignal in { + ...instantiation.inputMapping.values, + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + }) { + final resolved = mappedSignal.resolved; + exact.putIfAbsent(resolved, () => {}).add(instantiation); + byReferenceBase + .putIfAbsent(_referenceBase(resolved), () => {}) + .add(instantiation); + } + } + return (exact: exact, byReferenceBase: byReferenceBase); + } + + /// Whether [signal] is an element of a one-dimensional packed bit array. + bool _isPackedBitArrayElement(SynthLogic signal) { + if (signal is! SynthLogicArrayElement) { + return false; + } + final parentLogic = signal.parentArray.resolved.logics.singleOrNull; + return parentLogic is LogicArray && + parentLogic.dimensions.length == 1 && + parentLogic.elementWidth == 1 && + parentLogic.numUnpackedDimensions == 0; + } + /// Signal names are selected through [Namer.signalNameOfBest] or kept as /// literal constants. Submodule names are selected through /// [Namer.instanceNameOf]. All non-constant names share a single namespace /// managed by the module's [Namer]. void _pickNames() { - // first ports get priority // Name allocation order matters -- earlier claims receive the unsuffixed // name when there are collisions. Weak-name claimants are intentionally // deferred so emitted objects receive 1st chance at the shortest basenames: @@ -1044,7 +1374,6 @@ class SynthModuleDefinition { for (final inOut in inOuts) { inOut.pickName(); } - // Reserved submodule instances first (they assert their exact name). for (final submodule in subModuleInstantiations) { if (submodule.module.reserveName) { @@ -1095,8 +1424,8 @@ class SynthModuleDefinition { } } - /// Merges bit blasted array assignments into one single assignment when - /// it's full array-full array assignment + /// Merges bit blasted array assignments into fewer assignments when they are + /// full array-to-array assignments or contiguous same-offset array ranges. void _collapseArrays() { final boringArrayPairs = <(SynthLogic, SynthLogic)>[]; @@ -1112,15 +1441,13 @@ class SynthModuleDefinition { final dst = assignment.dst; if (src is SynthLogicArrayElement && dst is SynthLogicArrayElement) { - final srcArray = src.parentArray; - final dstArray = dst.parentArray; + final srcArray = src.parentArray.resolved; + final dstArray = dst.parentArray.resolved; assert(srcArray.logics.length == 1, 'should be 1 name for the array'); assert(dstArray.logics.length == 1, 'should be 1 name for the array'); - if (srcArray.logics.first.elements.length != - dstArray.logics.first.elements.length || - boringArrayPairs.contains((srcArray, dstArray))) { + if (boringArrayPairs.contains((srcArray, dstArray))) { reducedAssignments.add(assignment); } else { groupedAssignments[(srcArray, dstArray)] ??= []; @@ -1133,14 +1460,9 @@ class SynthModuleDefinition { for (final MapEntry(key: (srcArray, dstArray), value: arrAssignments) in groupedAssignments.entries) { - assert( - srcArray.logics.first.elements.length == - dstArray.logics.first.elements.length, - 'should be equal lengths of elements in both arrays by now', - ); - // first requirement is that all elements have been assigned - var shouldMerge = + var shouldMerge = srcArray.logics.first.elements.length == + dstArray.logics.first.elements.length && arrAssignments.length == srcArray.logics.first.elements.length; if (shouldMerge) { @@ -1161,7 +1483,9 @@ class SynthModuleDefinition { if (shouldMerge) { reducedAssignments.add(SynthAssignment(srcArray, dstArray)); } else { - reducedAssignments.addAll(arrAssignments); + reducedAssignments.addAll( + _collapseArrayElementRanges(srcArray, dstArray, arrAssignments), + ); boringArrayPairs.add((srcArray, dstArray)); } } @@ -1173,6 +1497,1843 @@ class SynthModuleDefinition { } } + /// Collapses contiguous same-offset element assignments into range + /// assignments, when the array shapes are simple enough to render as packed + /// ranges across the first dimension. + List _collapseArrayElementRanges( + SynthLogic srcArray, + SynthLogic dstArray, + List arrAssignments, + ) { + if (!_canCollapseArrayElementRanges(srcArray, dstArray) || + arrAssignments.length < 2) { + return arrAssignments; + } + + int srcIndex(SynthAssignment assignment) => + (assignment.src as SynthLogicArrayElement).logic.arrayIndex!; + int dstIndex(SynthAssignment assignment) => + (assignment.dst as SynthLogicArrayElement).logic.arrayIndex!; + + final seenDstIndices = {}; + final assignmentsByOffset = >{}; + for (final assignment in arrAssignments) { + final srcElementIndex = srcIndex(assignment); + final dstElementIndex = dstIndex(assignment); + if (!seenDstIndices.add(dstElementIndex)) { + return arrAssignments; + } + assignmentsByOffset + .putIfAbsent(dstElementIndex - srcElementIndex, () => []) + .add(assignment); + } + + final collapsedAssignments = <({int dstLowerIndex, SynthAssignment a})>[]; + + void addRun(List group, int start, int end) { + if (end == start) { + final assignment = group[start]; + collapsedAssignments.add(( + dstLowerIndex: dstIndex(assignment), + a: assignment, + )); + return; + } + + final first = group[start]; + final last = group[end]; + collapsedAssignments.add(( + dstLowerIndex: dstIndex(first), + a: RangeSynthAssignment( + srcArray, + dstArray, + srcUpperIndex: srcIndex(last), + srcLowerIndex: srcIndex(first), + dstUpperIndex: dstIndex(last), + dstLowerIndex: dstIndex(first), + ), + )); + } + + for (final group in assignmentsByOffset.values) { + group.sort((a, b) => dstIndex(a).compareTo(dstIndex(b))); + + for (final run in _contiguousRuns( + group, + (previous, current) => + dstIndex(current) == dstIndex(previous) + 1 && + srcIndex(current) == srcIndex(previous) + 1, + )) { + addRun(group, run.start, run.end); + } + } + + collapsedAssignments.sort( + (a, b) => a.dstLowerIndex.compareTo(b.dstLowerIndex), + ); + return [for (final assignment in collapsedAssignments) assignment.a]; + } + + /// Whether [srcArray] and [dstArray] are safe for range assignment collapse. + bool _canCollapseArrayElementRanges( + SynthLogic srcArray, + SynthLogic dstArray, + ) { + if (srcArray == dstArray || srcArray.isNet || dstArray.isNet) { + return false; + } + if (srcArray.logics.length != 1 || dstArray.logics.length != 1) { + return false; + } + final srcLogic = srcArray.logics.first; + final dstLogic = dstArray.logics.first; + if (srcLogic is! LogicArray || dstLogic is! LogicArray) { + return false; + } + + return srcLogic.dimensions.length == 1 && + dstLogic.dimensions.length == 1 && + srcLogic.elementWidth == 1 && + dstLogic.elementWidth == 1 && + srcLogic.numUnpackedDimensions == 0 && + dstLogic.numUnpackedDimensions == 0; + } + + /// Width-oriented collapse of bit-blasted assignments into packed ranges. + /// + /// This pass looks for individual one-bit assignments that are really pieces + /// of the same packed connection. For example, a sequence like + /// `dst[1] <= src[13]`, `dst[2] <= src[14]`, ... can become one + /// `dst[4:1] <= src[16:13]` [RangeSynthAssignment]. Sources may be direct + /// array elements or temporary [BusSubset] outputs; temporary outputs are + /// traced back to the original packed base before grouping. + /// + /// Candidates are grouped by source base, destination base, and constant + /// index offset. A group is then split into contiguous runs, so the pass + /// grows assignments in width without composing through arbitrary depth. + /// Depth composition through intermediates is handled by + /// [_collapseChainedRangeAssignments]. + void _collapseSimpleRangeAssignments() { + final busSubsetRanges = _busSubsetSourceRanges(); + if (busSubsetRanges.isEmpty) { + return; + } + + final generatedSubsetSets = _generatedSubsetIntermediateSets(); + final generatedSubsetCandidates = generatedSubsetSets.candidates; + final generatedSubsetIntermediates = generatedSubsetSets.intermediates; + + final swizzleInputSignals = { + for (final instantiation in subModuleInstantiations) + if (instantiation.module is Swizzle && + !(instantiation.module as Swizzle).isNet) + ...instantiation.inputMapping.values.map((signal) => signal.resolved), + }; + final assignmentsBySourceBase = _assignmentsBy( + assignments, (assignment) => _referenceBase(assignment.src)); + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySource = + _assignmentsBy(assignments, (assignment) => assignment.src.resolved); + final submoduleSignalUses = _submoduleSignalUseIndex(); + final knownSourceRanges = { + for (final entry in busSubsetRanges.entries) entry.key: entry.value.range, + }; + + final reducedAssignments = []; + final groupedCandidates = <(SynthLogic, SynthLogic, int), + List< + ({ + SynthAssignment assignment, + _SynthRangeRef src, + _SynthRangeRef dst, + SynthSubModuleInstantiation? sourceSubmodule, + SynthLogic? sourceSignal, + })>>{}; + + for (final assignment in assignments) { + if (assignment is PartialSynthAssignment) { + reducedAssignments.add(assignment); + continue; + } + + final src = _simpleAssignmentSourceRange(assignment, busSubsetRanges); + final resolvedSrc = src == null + ? null + : ( + range: _resolveKnownRangeThroughFullWidthDrivers( + src.range, + assignmentsByDestination, + knownSourceRanges, + ), + sourceSubmodule: src.sourceSubmodule, + sourceSignal: src.sourceSignal, + ); + final dst = _simpleAssignmentDestinationRange(assignment); + // A candidate is one bit of a future cross-object range assignment: + // `dstBase[dstBit] <= srcBase[srcBit]`. The bases must be different; + // this pass is packing plumbing between objects, not proving that an + // overlapping intra-object move like `a[4:1] <= a[16:13]` is safe. + if (resolvedSrc == null || + dst == null || + resolvedSrc.range.width != 1 || + dst.width != 1 || + resolvedSrc.range.base == dst.base || + (resolvedSrc.sourceSubmodule != null && + resolvedSrc.range.lower != dst.lower && + !generatedSubsetIntermediates.contains(dst.base)) || + (resolvedSrc.sourceSubmodule != null && + !_hasOnlySwizzleConsumers( + dst.base, + assignmentsBySourceBase, + swizzleInputSignals, + )) || + !_canUsePackedRangeSource(resolvedSrc.range.base) || + !_canUsePackedRangeBase(dst.base)) { + reducedAssignments.add(assignment); + continue; + } + + // Keep only assignments that share a stable bit offset. Each group can + // later become a packed range assignment such as + // `dstBase[4:1] <= srcBase[16:13]`. + groupedCandidates.putIfAbsent(( + resolvedSrc.range.base, + dst.base, + dst.lower - resolvedSrc.range.lower + ), () => []).add(( + assignment: assignment, + src: resolvedSrc.range, + dst: dst, + sourceSubmodule: resolvedSrc.sourceSubmodule, + sourceSignal: resolvedSrc.sourceSignal, + )); + } + + // Generated `assignSubset` arrays need full-coverage accounting. If only + // part of such an array is collapsed away, the remaining helper can change + // undriven/floating behavior. A fully covered helper can be replaced as a + // single range safely; partial helpers stay conservative unless they are + // known generated intermediates with live sources. + final fullyCoveredGeneratedSubsets = {}; + final generatedSubsetBits = >{}; + for (final group in groupedCandidates.values) { + for (final candidate in group) { + if (generatedSubsetCandidates.contains(candidate.dst.base)) { + generatedSubsetBits + .putIfAbsent(candidate.dst.base, () => {}) + .add(candidate.dst.lower); + } + } + } + for (final entry in generatedSubsetBits.entries) { + if (entry.value.length == entry.key.width && + Iterable.generate(entry.key.width).every(entry.value.contains)) { + fullyCoveredGeneratedSubsets.add(entry.key); + } + } + + var changed = false; + for (final group in groupedCandidates.values) { + group.sort((a, b) => a.dst.lower.compareTo(b.dst.lower)); + + for (final run in _contiguousRuns( + group, + (previous, current) => + current.dst.lower == previous.dst.lower + 1 && + current.src.lower == previous.src.lower + 1, + )) { + changed |= _addSimpleRangeRun( + reducedAssignments, + group, + run.start, + run.end, + assignmentsBySource, + generatedSubsetCandidates, + generatedSubsetIntermediates, + fullyCoveredGeneratedSubsets, + submoduleSignalUses, + ); + } + } + + if (changed) { + assignments + ..clear() + ..addAll(reducedAssignments); + } + } + + /// Whether every assignment consuming [base] feeds a [Swizzle] input. + bool _hasOnlySwizzleConsumers( + SynthLogic base, + Map> assignmentsBySourceBase, + Set swizzleInputSignals, + ) { + final consumers = assignmentsBySourceBase[base] ?? const []; + return consumers.isNotEmpty && + consumers.every( + (assignment) => swizzleInputSignals.contains(assignment.dst.resolved), + ); + } + + /// Adds one contiguous run from [_collapseSimpleRangeAssignments]. + /// + /// Single-bit runs are left alone because there is no width to recover. Wider + /// runs become [RangeSynthAssignment]s. When the source bits came from a + /// temporary [BusSubset], the temporary module and signal are cleared only if + /// every remaining use is covered by the assignments being replaced. + bool _addSimpleRangeRun( + List reducedAssignments, + List< + ({ + SynthAssignment assignment, + _SynthRangeRef src, + _SynthRangeRef dst, + SynthSubModuleInstantiation? sourceSubmodule, + SynthLogic? sourceSignal, + })> + group, + int start, + int end, + Map> assignmentsBySource, + Set generatedSubsetCandidates, + Set generatedSubsetIntermediates, + Set fullyCoveredGeneratedSubsets, + _SubmoduleSignalUseIndex submoduleSignalUses, + ) { + if (start == end) { + reducedAssignments.add(group[start].assignment); + return false; + } + + final first = group[start]; + final last = group[end]; + if (generatedSubsetCandidates.contains(first.dst.base) && + !fullyCoveredGeneratedSubsets.contains(first.dst.base) && + (!generatedSubsetIntermediates.contains(first.dst.base) || + !group.getRange(start, end + 1).every((candidate) => + _canPartiallyCollapseGeneratedSubsetSource( + candidate.src.base)))) { + for (var index = start; index <= end; index++) { + reducedAssignments.add(group[index].assignment); + } + return false; + } + + // Selecting a range directly from a literal is not legal SystemVerilog. + // A run that covers the whole constant instead becomes a partial + // destination assignment, such as `dst[6:4] = 3'h0`. + reducedAssignments.add(first.src.base.isConstant && + first.src.lower == 0 && + last.src.upper == first.src.base.width - 1 + ? PartialSynthAssignment( + first.src.base, + first.dst.base, + dstUpperIndex: last.dst.upper, + dstLowerIndex: first.dst.lower, + ) + : RangeSynthAssignment( + first.src.base, + first.dst.base, + srcUpperIndex: last.src.upper, + srcLowerIndex: first.src.lower, + dstUpperIndex: last.dst.upper, + dstLowerIndex: first.dst.lower, + )); + final replacedAssignments = { + for (var index = start; index <= end; index++) group[index].assignment, + }; + for (var index = start; index <= end; index++) { + final sourceSignal = group[index].sourceSignal?.resolved; + final sourceSubmodule = group[index].sourceSubmodule; + if (sourceSignal != null && + sourceSubmodule != null && + _canClearReplacedSourceSignal( + sourceSignal, + assignmentsBySource, + replacedAssignments, + submoduleSignalUses: submoduleSignalUses, + allowedInstantiation: sourceSubmodule, + )) { + sourceSubmodule.clearInstantiation(); + sourceSignal.clearDeclaration(); + } + } + return true; + } + + /// Whether a replaced temporary source has no assignment or submodule users + /// outside [replacedAssignments] and [allowedInstantiation]. + bool _canClearReplacedSourceSignal( + SynthLogic sourceSignal, + Map> assignmentsBySource, + Set replacedAssignments, { + required _SubmoduleSignalUseIndex submoduleSignalUses, + required SynthSubModuleInstantiation allowedInstantiation, + }) { + final assignmentUsers = assignmentsBySource[sourceSignal] ?? const []; + return assignmentUsers.every(replacedAssignments.contains) && + !_hasSubmoduleSignalUse( + sourceSignal, + submoduleSignalUses, + allowedInstantiation: allowedInstantiation, + ); + } + + /// Whether [signal] is mapped by an instantiation other than + /// [allowedInstantiation]. + bool _hasSubmoduleSignalUse( + SynthLogic signal, + _SubmoduleSignalUseIndex submoduleSignalUses, { + required SynthSubModuleInstantiation allowedInstantiation, + }) { + final resolved = signal.resolved; + final users = { + ...?submoduleSignalUses.exact[resolved], + ...?submoduleSignalUses.byReferenceBase[resolved], + ...?submoduleSignalUses.exact[_referenceBase(resolved)], + }; + return users.any((instantiation) => instantiation != allowedInstantiation); + } + + /// Whether [sourceBase] remains meaningful when only part of a generated + /// subset helper is collapsed. + bool _canPartiallyCollapseGeneratedSubsetSource(SynthLogic sourceBase) => + (sourceBase.isConstant && !sourceBase.isFloatingConstant) || + _isLiveRangeSource(sourceBase); + + /// Whether [source] resolves through full-width drivers to a constant. + /// + /// [visiting] prevents cycles in malformed or bidirectional connection + /// graphs from recursing indefinitely. + bool _isConstantBackedSource( + SynthLogic source, + Map> assignmentsByDestination, [ + Set visiting = const {}, + ]) { + if (source.isConstant) { + return true; + } + if (visiting.contains(source)) { + return false; + } + + final driver = _singleFullWidthAssignment( + source, + assignmentsByDestination, + ); + return driver != null && + _isConstantBackedSource( + _assignmentSourceRange(driver).base, + assignmentsByDestination, + {...visiting, source}, + ); + } + + /// Whether [sourceBase] has a live identity or connection outside disposable + /// range intermediates. + bool _isLiveRangeSource(SynthLogic sourceBase) => + !sourceBase.isConstant && + (sourceBase.hasSrcConnectionsPresent() || + sourceBase.isStructPortElement(module) || + !internalSignals.contains(sourceBase) || + !sourceBase.isClearable); + + /// Composes temporary bus slices through full-width assignments into wide + /// array-element destinations. + void _collapseWideArrayElementRangeSources() { + final busSubsetRanges = _busSubsetSourceRanges(); + if (busSubsetRanges.isEmpty) { + return; + } + + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySource = + _assignmentsBy(assignments, (assignment) => assignment.src.resolved); + final submoduleSignalUses = _submoduleSignalUseIndex(); + final knownSourceRanges = { + for (final entry in busSubsetRanges.entries) entry.key: entry.value.range, + }; + + var changed = false; + final updatedAssignments = []; + for (final assignment in assignments) { + if (assignment is PartialSynthAssignment || + assignment.dst.resolved is! SynthLogicArrayElement) { + updatedAssignments.add(assignment); + continue; + } + + final busSubsetRange = busSubsetRanges[assignment.src.resolved]; + if (busSubsetRange == null) { + updatedAssignments.add(assignment); + continue; + } + if (!internalSignals.contains(busSubsetRange.range.base) || + !busSubsetRange.range.base.isClearable) { + updatedAssignments.add(assignment); + continue; + } + + final dst = assignment.dst.resolved; + if (dst.width <= 1 || dst.logics.any((logic) => logic is LogicArray)) { + updatedAssignments.add(assignment); + continue; + } + + final src = _resolveKnownRangeThroughFullWidthDrivers( + busSubsetRange.range, + assignmentsByDestination, + knownSourceRanges, + ); + if (src.base == dst || + src.width != dst.width || + !_canUsePackedRangeBase(src.base) || + !_isLiveRangeSource(src.base) || + (internalSignals.contains(src.base) && src.base.isClearable) || + dst.isNet || + dst.isConstant) { + updatedAssignments.add(assignment); + continue; + } + + updatedAssignments.add( + RangeSynthAssignment( + src.base, + dst, + srcUpperIndex: src.upper, + srcLowerIndex: src.lower, + dstUpperIndex: dst.width - 1, + dstLowerIndex: 0, + ), + ); + if (_canClearReplacedSourceSignal( + assignment.src.resolved, + assignmentsBySource, + {assignment}, + submoduleSignalUses: submoduleSignalUses, + allowedInstantiation: busSubsetRange.inst, + )) { + busSubsetRange.inst.clearInstantiation(); + assignment.src.clearDeclaration(); + } + changed = true; + } + + if (changed) { + assignments + ..clear() + ..addAll(updatedAssignments); + } + } + + /// Maps each usable non-net [BusSubset] output to its packed source range and + /// helper instantiation, resolving through full-width temporary drivers. + Map + _busSubsetSourceRanges() { + final directRanges = {}; + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + + for (final instantiation in subModuleInstantiations) { + final module = instantiation.module; + if (module is! BusSubset || + module.original.isNet || + module.startIndex > module.endIndex) { + continue; + } + + final original = instantiation.inputMapping[module.original.name]; + final subset = instantiation.outputMapping[module.subset.name]; + if (original == null || + subset == null || + original.isNet || + subset.isNet || + !_canUsePackedRangeSource(original.resolved)) { + continue; + } + + directRanges[subset.resolved] = ( + range: _SynthRangeRef( + original.resolved, + module.startIndex, + module.endIndex, + ), + inst: instantiation, + ); + } + + final ranges = {}; + for (final entry in directRanges.entries) { + final resolvedRange = _resolveFullWidthDrivenRange( + entry.value.range, + assignmentsByDestination, + directRanges, + ); + if (!_canUsePackedRangeSource(resolvedRange.base)) { + continue; + } + + ranges[entry.key] = (range: resolvedRange, inst: entry.value.inst); + } + return ranges; + } + + /// Resolves [range] through full-width assignments and known [BusSubset] + /// ranges while preserving its relative bit offsets. + _SynthRangeRef _resolveFullWidthDrivenRange( + _SynthRangeRef range, + Map> assignmentsByDestination, + Map + directRanges, [ + Set visiting = const {}, + ]) { + if (visiting.contains(range.base)) { + return range; + } + + final sourceRange = _singleFullWidthSourceRange( + range.base, + assignmentsByDestination, + directRanges, + {...visiting, range.base}, + ); + if (sourceRange == null) { + return range; + } + + return _SynthRangeRef.tryCreate( + sourceRange.base, + sourceRange.lower + range.lower, + sourceRange.lower + range.upper, + ) ?? + range; + } + + /// Returns the source range of [signal]'s sole full-width driver. + /// + /// Known [BusSubset] sources are recursively resolved, with [visiting] + /// preventing cycles. + _SynthRangeRef? _singleFullWidthSourceRange( + SynthLogic signal, + Map> assignmentsByDestination, + Map + directRanges, + Set visiting, + ) { + final driver = _singleFullWidthAssignment(signal, assignmentsByDestination); + if (driver == null) { + return null; + } + + if (driver is RangeSynthAssignment) { + return _SynthRangeRef( + driver.src.resolved, + driver.srcLowerIndex, + driver.srcUpperIndex, + ); + } + + final driverSrc = driver.src.resolved; + final directRange = directRanges[driverSrc]; + if (directRange != null) { + return _resolveFullWidthDrivenRange( + directRange.range, + assignmentsByDestination, + directRanges, + visiting, + ); + } + + return _SynthRangeRef(driverSrc, 0, driverSrc.width - 1); + } + + /// Returns [signal]'s only assignment when it covers the full destination. + SynthAssignment? _singleFullWidthAssignment( + SynthLogic signal, + Map> assignmentsByDestination, + ) { + final drivers = assignmentsByDestination[signal.resolved]; + if (drivers == null || drivers.length != 1) { + return null; + } + + final driver = drivers.single; + if (driver is RangeSynthAssignment) { + if (driver.dstLowerIndex != 0 || + driver.dstUpperIndex != signal.width - 1 || + driver.width != signal.width) { + return null; + } + return driver; + } + + if (driver is PartialSynthAssignment || driver.width != signal.width) { + return null; + } + + return driver; + } + + /// Resolves the packed source represented by an array-element assignment or + /// a temporary [BusSubset] output. + ({ + _SynthRangeRef range, + SynthSubModuleInstantiation? sourceSubmodule, + SynthLogic? sourceSignal, + })? _simpleAssignmentSourceRange( + SynthAssignment assignment, + Map + busSubsetRanges, + ) { + final src = assignment.src.resolved; + final busSubsetRange = busSubsetRanges[src]; + if (busSubsetRange != null) { + return ( + range: busSubsetRange.range, + sourceSubmodule: busSubsetRange.inst, + sourceSignal: src, + ); + } + + final arrayElementRange = _arrayElementRange(src); + if (arrayElementRange == null) { + return null; + } + return ( + range: arrayElementRange, + sourceSubmodule: null, + sourceSignal: null, + ); + } + + /// Returns the packed destination range for an array-element assignment. + _SynthRangeRef? _simpleAssignmentDestinationRange( + SynthAssignment assignment) => + _arrayElementRange(assignment.dst.resolved); + + /// Returns the one-bit packed range represented by [signal], when its parent + /// array has a supported packed layout. + _SynthRangeRef? _arrayElementRange(SynthLogic signal) { + if (signal is! SynthLogicArrayElement) { + return null; + } + final parentArray = signal.parentArray.resolved; + if (!_canUsePackedRangeBase(parentArray)) { + return null; + } + final index = signal.logic.arrayIndex!; + return _SynthRangeRef(parentArray, index, index); + } + + /// Whether [base] can be referenced by packed indices in a backend-neutral + /// range assignment. + bool _canUsePackedRangeBase(SynthLogic base) { + if (base.isNet || base.isConstant) { + return false; + } + if (!base.isArray) { + return true; + } + if (base.logics.length != 1) { + return false; + } + final logic = base.logics.first; + return logic is LogicArray && + logic.dimensions.length == 1 && + logic.elementWidth == 1 && + logic.numUnpackedDimensions == 0; + } + + /// Constants can be regrouped after named constant subsets are bit-blasted, + /// but floating constants must retain their undriven semantics. + bool _canUsePackedRangeSource(SynthLogic base) => + (base.isConstant && !base.isFloatingConstant) || + _canUsePackedRangeBase(base); + + /// Depth-oriented composition of range assignments through an intermediate. + /// + /// Unlike [_collapseSimpleRangeAssignments], this pass does not try to find + /// more adjacent bits. It looks for a single producer and a single consumer + /// around an internal helper and composes their ranges, for example + /// `mid[3:0] <= src[7:4]` plus `dst[1:0] <= mid[3:2]` becoming + /// `dst[1:0] <= src[7:6]`. + void _collapseChainedRangeAssignments() { + final generatedSubsetIntermediates = _generatedSubsetIntermediates(); + final mappedIntermediates = {}; + for (final instantiation in subModuleInstantiations) { + for (final mapped in { + ...instantiation.inputMapping.values, + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + }) { + mappedIntermediates.add(mapped.resolved); + if (mapped is SynthLogicArrayElement) { + mappedIntermediates.add(mapped.parentArray.resolved); + } + } + } + final activeAssignments = LinkedHashSet.of(assignments); + final assignmentsByDestination = >{}; + final assignmentsBySource = >{}; + + /// Adds [assignment] to both mutable chain indexes. + void addToIndexes(SynthAssignment assignment) { + assignmentsByDestination + .putIfAbsent(_referenceBase(assignment.dst), () => {}) + .add(assignment); + assignmentsBySource + .putIfAbsent(_referenceBase(assignment.src), () => {}) + .add(assignment); + } + + /// Removes [assignment] from both mutable chain indexes. + void removeFromIndexes(SynthAssignment assignment) { + assignmentsByDestination[_referenceBase(assignment.dst)] + ?.remove(assignment); + assignmentsBySource[_referenceBase(assignment.src)]?.remove(assignment); + } + + assignments.forEach(addToIndexes); + final workQueue = ListQueue.from( + assignments.whereType(), + ); + + while (workQueue.isNotEmpty) { + final producer = workQueue.removeFirst(); + if (!activeAssignments.contains(producer)) { + continue; + } + + final intermediate = producer.dst.resolved; + final producers = assignmentsByDestination[intermediate]; + final consumers = assignmentsBySource[intermediate]; + if (producers?.length != 1 || + consumers?.length != 1 || + producers!.single != producer) { + continue; + } + + final consumer = consumers!.single; + final replacement = _composeChainedRangeAssignment( + producer: producer, + consumer: consumer, + intermediate: intermediate, + generatedSubsetIntermediates: generatedSubsetIntermediates, + mappedIntermediates: mappedIntermediates, + ); + if (replacement == null) { + continue; + } + + activeAssignments + ..remove(producer) + ..remove(consumer); + removeFromIndexes(producer); + removeFromIndexes(consumer); + activeAssignments.add(replacement); + addToIndexes(replacement); + workQueue.add(replacement); + intermediate.clearDeclaration(); + } + + assignments + ..clear() + ..addAll(activeAssignments); + } + + /// Composes [producer] and [consumer] through [intermediate]. + /// + /// The selected intermediate ranges must contain one another so their + /// offsets can be translated without changing width or overlap semantics. + /// Generated `assignSubset` intermediates additionally allow a producer to + /// replace part of a wider consumer because their complete coverage is + /// validated by the generated-subset analysis. + RangeSynthAssignment? _composeChainedRangeAssignment({ + required PartialSynthAssignment producer, + required SynthAssignment consumer, + required SynthLogic intermediate, + required Set generatedSubsetIntermediates, + required Set mappedIntermediates, + }) { + if (!_isRangeChainIntermediate( + intermediate, + mappedIntermediates: mappedIntermediates, + )) { + return null; + } + + final producerSrc = _assignmentSourceRange(producer); + final producerDst = _assignmentDestinationRange(producer); + final consumerSrc = _assignmentSourceRange(consumer); + final consumerDst = _assignmentDestinationRange(consumer); + + if (producerDst.base != intermediate || consumerSrc.base != intermediate) { + return null; + } + if (producerSrc.base == consumerDst.base || + producerSrc.base.isNet || + consumerDst.base.isNet || + producerSrc.base.isConstant || + consumerDst.base.isConstant) { + return null; + } + + late final _SynthRangeRef replacementSrc; + late final _SynthRangeRef replacementDst; + if (consumer is PartialSynthAssignment) { + if (!producerDst.contains(consumerSrc)) { + return null; + } + + final sourceLower = + producerSrc.lower + (consumerSrc.lower - producerDst.lower); + final sourceUpper = + producerSrc.lower + (consumerSrc.upper - producerDst.lower); + final sourceRange = _SynthRangeRef.tryCreate( + producerSrc.base, + sourceLower, + sourceUpper, + ); + if (sourceRange == null) { + return null; + } + replacementSrc = sourceRange; + replacementDst = consumerDst; + } else { + if (!consumerSrc.contains(producerDst)) { + return null; + } + final producerCoversWholeConsumer = + producerDst.lower == consumerSrc.lower && + producerDst.upper == consumerSrc.upper; + if (!producerCoversWholeConsumer) { + if (!generatedSubsetIntermediates.contains(intermediate) || + consumerDst.base is SynthLogicArrayElement) { + return null; + } + } + + final dstLower = + consumerDst.lower + (producerDst.lower - consumerSrc.lower); + final dstUpper = + consumerDst.lower + (producerDst.upper - consumerSrc.lower); + final destinationRange = _SynthRangeRef.tryCreate( + consumerDst.base, + dstLower, + dstUpper, + ); + if (destinationRange == null) { + return null; + } + replacementSrc = producerSrc; + replacementDst = destinationRange; + } + + if (replacementSrc.width != replacementDst.width || + replacementSrc.lower < 0 || + replacementSrc.upper >= replacementSrc.base.width || + replacementDst.lower < 0 || + replacementDst.upper >= replacementDst.base.width) { + return null; + } + + return RangeSynthAssignment( + replacementSrc.base, + replacementDst.base, + srcUpperIndex: replacementSrc.upper, + srcLowerIndex: replacementSrc.lower, + dstUpperIndex: replacementDst.upper, + dstLowerIndex: replacementDst.lower, + ); + } + + /// Replaces a named constant intermediate with its literal at each range + /// consumer. + /// + /// For example, `tie = 3'h0` plus `bus[6:4] = tie` becomes + /// `bus[6:4] = 3'h0`. The intermediate is removed only when every consumer + /// was replaced, so fanout and unsupported uses remain conservative. + void _collapseConstantBackedRangeIntermediates() { + final mappedSignals = _submoduleMappingReferences( + includeInputs: true, + includeOutputs: true, + ); + while (true) { + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySource = + _assignmentsBy(assignments, (assignment) => assignment.src.resolved); + final claimedAssignments = {}; + final replacements = {}; + final removedProducers = {}; + final clearedIntermediates = {}; + + for (final intermediate in internalSignals.toList()) { + final producers = assignmentsByDestination[intermediate.resolved]; + final consumers = assignmentsBySource[intermediate.resolved]; + if (producers?.length != 1 || + consumers == null || + consumers.isEmpty || + intermediate.hasPreservedName || + mappedSignals.contains(intermediate.resolved)) { + continue; + } + + final producer = producers!.single; + if (claimedAssignments.contains(producer)) { + continue; + } + if (intermediate.width == 0 || + producer.src.width == 0 || + producer.dst.width == 0) { + continue; + } + final producerSrc = _assignmentSourceRange(producer); + final producerDst = _assignmentDestinationRange(producer); + if (!producerSrc.base.isConstant || + producerSrc.base.isFloatingConstant || + producerSrc.lower != 0 || + producerSrc.upper != producerSrc.base.width - 1 || + producerDst.base != intermediate.resolved || + producerDst.lower != 0 || + producerDst.upper != intermediate.width - 1) { + continue; + } + + final intermediateReplacements = + {}; + for (final consumer in consumers) { + if (consumer is! PartialSynthAssignment || + claimedAssignments.contains(consumer)) { + continue; + } + final consumerSrc = _assignmentSourceRange(consumer); + final consumerDst = _assignmentDestinationRange(consumer); + final feedsArraySubset = + (assignmentsBySource[consumerDst.base.resolved] ?? const []) + .any((downstream) => downstream.dst.dstConnections.any( + (destination) => destination.isArrayMember, + )); + if (consumerSrc.base != intermediate.resolved || + _referenceBase(consumerDst.base).isArray || + feedsArraySubset || + consumerSrc.lower != 0 || + consumerSrc.upper != intermediate.width - 1 || + consumerDst.width != producerSrc.width) { + continue; + } + intermediateReplacements[consumer] = PartialSynthAssignment( + producerSrc.base, + consumerDst.base, + dstUpperIndex: consumerDst.upper, + dstLowerIndex: consumerDst.lower, + ); + } + if (intermediateReplacements.isEmpty) { + continue; + } + + replacements.addAll(intermediateReplacements); + claimedAssignments.addAll(intermediateReplacements.keys); + if (intermediateReplacements.length == consumers.length) { + claimedAssignments.add(producer); + removedProducers.add(producer); + clearedIntermediates.add(intermediate); + } + } + + if (replacements.isEmpty) { + break; + } + + final updatedAssignments = [ + for (final assignment in assignments) + if (replacements.containsKey(assignment)) + replacements[assignment]! + else if (!removedProducers.contains(assignment)) + assignment, + ]; + assignments + ..clear() + ..addAll(updatedAssignments); + for (final intermediate in clearedIntermediates) { + intermediate.clearDeclaration(); + } + internalSignals.removeAll(clearedIntermediates); + } + } + + /// Drops constant-backed signals referenced only by cleared [BusSubset] + /// helpers. + /// + /// Active packed-array mappings and current-module array ports are excluded + /// because their elements can still depend on a constant after ordinary + /// assignment pruning. + void _pruneClearedSubsetConstantSources() { + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySource = + _assignmentsBy(assignments, (assignment) => assignment.src.resolved); + final mappedInstantiationsBySignal = + >{}; + for (final instantiation in subModuleInstantiations) { + for (final mapped in { + ...instantiation.inputMapping.values, + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + }) { + mappedInstantiationsBySignal + .putIfAbsent(mapped.resolved, () => {}) + .add(instantiation); + } + } + + final protectedArrayBases = { + for (final port in [...inputs, ...outputs, ...inOuts]) + if (_referenceBase(port.resolved).isArray) _arrayRoot(port.resolved), + for (final instantiation in subModuleInstantiations) + if (instantiation.needsInstantiation) + for (final mapped in instantiation.inputMapping.values) + if (_referenceBase(mapped.resolved).isArray) + _arrayRoot(mapped.resolved), + }; + final removedAssignments = {}; + final removedSignals = {}; + for (final signal in internalSignals.toList()) { + final producers = assignmentsByDestination[signal.resolved] ?? const []; + final consumers = assignmentsBySource[signal.resolved] ?? const []; + final constantBacked = signal.isConstant || + (producers.isNotEmpty && + producers.every( + (producer) => producer.src.resolved.isConstant, + )); + if (!constantBacked || + signal.hasPreservedName || + consumers.isNotEmpty || + (signal is SynthLogicArrayElement && + protectedArrayBases.contains(_arrayRoot(signal))) || + signal.dstConnections.any((destination) { + final synthDestination = getSynthLogic(destination)?.resolved; + return synthDestination != null && + protectedArrayBases.contains( + _arrayRoot(synthDestination), + ); + })) { + continue; + } + + final mappedInstantiations = + mappedInstantiationsBySignal[signal.resolved] ?? const {}; + final feedsProtectedArray = mappedInstantiations.any( + (instantiation) => + !instantiation.needsInstantiation && + instantiation.module is BusSubset && + instantiation.inputMapping.values.any( + (mapped) => mapped.resolved == signal.resolved, + ) && + instantiation.outputMapping.values.any( + (mapped) => protectedArrayBases.contains(_arrayRoot(mapped)), + ), + ); + if (feedsProtectedArray) { + continue; + } + if (mappedInstantiations.isEmpty || + mappedInstantiations.every( + (instantiation) => + !instantiation.needsInstantiation && + instantiation.module is BusSubset, + )) { + removedAssignments.addAll(producers); + removedSignals.add(signal); + } + } + + if (removedAssignments.isNotEmpty) { + final retainedAssignments = [ + for (final assignment in assignments) + if (!removedAssignments.contains(assignment)) assignment, + ]; + assignments + ..clear() + ..addAll(retainedAssignments); + } + for (final signal in removedSignals) { + signal.clearDeclaration(); + } + internalSignals.removeAll(removedSignals); + } + + /// Collapses generated `assignSubset` helpers feeding packed swizzles. + /// + /// `Logic.assignSubset` builds a temporary array and then swizzles that array + /// back into a packed value. When every producer bit for that helper is + /// accounted for, the helper and swizzle can be replaced with direct packed + /// range assignments to the swizzle output. This is a specialized helper + /// pass: it requires full coverage so partially driven helpers keep their + /// original undriven/floating behavior. + void _collapseGeneratedSubsetSwizzleRangeAssignments() { + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySourceBase = _assignmentsBy( + assignments, (assignment) => _referenceBase(assignment.src)); + + final allSwizzleSourceRanges = + _fullPackedSwizzleSourceRanges(assignmentsByDestination); + final generatedSubsetIntermediates = _generatedSubsetIntermediatesFrom( + allSwizzleSourceRanges, + assignmentsBySourceBase, + ); + final swizzleSourceRanges = { + for (final entry in allSwizzleSourceRanges.entries) + if (generatedSubsetIntermediates.contains(entry.value.range.base)) + entry.key: entry.value, + }; + if (swizzleSourceRanges.isEmpty) { + return; + } + + final swizzlesByBase = inputAssignments, + })>>{}; + for (final entry in swizzleSourceRanges.entries) { + swizzlesByBase.putIfAbsent(entry.value.range.base, () => []).add(( + output: entry.key, + range: entry.value.range, + inst: entry.value.inst, + inputAssignments: entry.value.inputAssignments, + )); + } + + final assignmentsByBaseDestination = _assignmentsBy( + assignments, (assignment) => _referenceBase(assignment.dst)); + final assignmentsBySource = _assignmentsBy( + assignments, (assignment) => _referenceBase(assignment.src)); + final submoduleSignalUses = _submoduleSignalUseIndex(); + final realOutputMappingsBySignal = >{}; + for (final instantiation in subModuleInstantiations) { + if (!instantiation.needsInstantiation || + instantiation.module is InlineSystemVerilog) { + continue; + } + for (final entry in instantiation.outputMapping.entries) { + realOutputMappingsBySignal + .putIfAbsent(entry.value.resolved, () => []) + .add((instantiation: instantiation, portName: entry.key)); + } + } + final knownSourceRanges = { + for (final entry in _busSubsetSourceRanges().entries) + entry.key: entry.value.range, + }; + + final replacements = {}; + final consumedAssignments = {}; + final outputMappingReplacements = <({ + SynthSubModuleInstantiation instantiation, + String portName, + SynthLogicPackedBitReference reference, + })>[]; + for (final entry in swizzlesByBase.entries) { + final intermediate = entry.key; + final swizzles = entry.value; + final swizzle = swizzles.singleOrNull; + final sourceUsers = assignmentsBySource[intermediate] ?? const []; + final producers = assignmentsByBaseDestination[intermediate]; + if (swizzles.length != 1 || + swizzle == null || + producers == null || + producers.isEmpty || + sourceUsers.any( + (sourceUser) => !swizzle.inputAssignments.contains(sourceUser), + )) { + continue; + } + + if (!_isRangeChainIntermediate( + intermediate, + allowedInstantiation: swizzle.inst, + )) { + continue; + } + + final output = swizzle.output.resolved; + if (output.isNet || output.isConstant) { + continue; + } + + final producerReplacements = {}; + final mappedProducerAssignments = {}; + final producerOutputMappingReplacements = <({ + SynthSubModuleInstantiation instantiation, + String portName, + SynthLogicPackedBitReference reference, + })>[]; + final resolvedProducerSources = { + for (final producer in producers) + producer: _resolveKnownRangeThroughFullWidthDrivers( + _assignmentSourceRange(producer), + assignmentsByDestination, + knownSourceRanges, + ), + }; + final commonSourceBase = resolvedProducerSources.values + .map((source) => source.base) + .toSet() + ..removeWhere((source) => !source.isArray); + final packedArraySource = commonSourceBase.singleOrNull; + final coveredPackedArrayBits = {}; + var preservesWholePackedArray = + packedArraySource != null && packedArraySource.width == output.width; + if (preservesWholePackedArray) { + for (final producer in producers) { + final source = resolvedProducerSources[producer]!; + final destination = _assignmentDestinationRange(producer); + if (source.base != packedArraySource || + destination.base != intermediate || + source.width != destination.width || + !swizzle.range.contains(destination) || + source.lower != destination.lower - swizzle.range.lower) { + preservesWholePackedArray = false; + break; + } + for (var bit = source.lower; bit <= source.upper; bit++) { + if (!coveredPackedArrayBits.add(bit)) { + preservesWholePackedArray = false; + break; + } + } + if (!preservesWholePackedArray) { + break; + } + } + } + if (preservesWholePackedArray && + coveredPackedArrayBits.length == output.width) { + // Keep complete ordered packed arrays on the existing swizzle path, + // which renders the array's packed selection without an unnecessary + // destination selection. + continue; + } + final hasConstantProducer = resolvedProducerSources.values.any( + (source) => _isConstantBackedSource( + source.base, + assignmentsByDestination, + ), + ); + final realMappedOutputProducerCount = producers + .where((producer) => + realOutputMappingsBySignal[producer.src.resolved]?.isNotEmpty ?? + false) + .length; + final seenDestinationBits = {}; + var canReplaceAll = true; + for (final producer in producers) { + final producerSrc = resolvedProducerSources[producer]!; + final producerDst = _assignmentDestinationRange(producer); + if (producerDst.base != intermediate || + !swizzle.range.contains(producerDst) || + producerSrc.base == output || + producerSrc.base.isNet || + (producerSrc.base.isConstant && + (producerSrc.lower != 0 || + producerSrc.upper != producerSrc.base.width - 1))) { + canReplaceAll = false; + break; + } + if (!producerSrc.base.isConstant && + !_isLiveRangeSource(producerSrc.base)) { + canReplaceAll = false; + break; + } + + final dstLower = producerDst.lower - swizzle.range.lower; + final dstUpper = producerDst.upper - swizzle.range.lower; + if (producerSrc.width != dstUpper - dstLower + 1 || + dstLower < 0 || + dstUpper >= output.width) { + canReplaceAll = false; + break; + } + for (var index = dstLower; index <= dstUpper; index++) { + if (!seenDestinationBits.add(index)) { + canReplaceAll = false; + break; + } + } + if (!canReplaceAll) { + break; + } + + final outputMappings = + realOutputMappingsBySignal[producer.src.resolved] ?? const []; + final producerSourceUsers = + assignmentsBySource[producer.src.resolved] ?? const []; + final canMapDirectly = realMappedOutputProducerCount == 1 && + (!hasConstantProducer || + producerDst.lower == 0 || + producerDst.upper == intermediate.width - 1); + if (canMapDirectly && + outputMappings.length == 1 && + producerSourceUsers.length == 1 && + producerSourceUsers.single == producer && + producerSrc.width == 1 && + producerDst.width == 1 && + !_hasSubmoduleSignalUse( + producer.src.resolved, + submoduleSignalUses, + allowedInstantiation: outputMappings.single.instantiation, + )) { + final packedReference = SynthLogicPackedBitReference( + output, + dstLower, + parentSynthModuleDefinition: this, + ); + producerOutputMappingReplacements.add(( + instantiation: outputMappings.single.instantiation, + portName: outputMappings.single.portName, + reference: packedReference, + )); + mappedProducerAssignments.add(producer); + } else { + producerReplacements[producer] = + producerSrc.base.width == 1 || producerSrc.base.isConstant + ? PartialSynthAssignment( + producerSrc.base, + output, + dstUpperIndex: dstUpper, + dstLowerIndex: dstLower, + ) + : RangeSynthAssignment( + producerSrc.base, + output, + srcUpperIndex: producerSrc.upper, + srcLowerIndex: producerSrc.lower, + dstUpperIndex: dstUpper, + dstLowerIndex: dstLower, + ); + } + } + if (!canReplaceAll) { + continue; + } + if (seenDestinationBits.length != output.width && + (output.logics.any((logic) => logic.isArrayMember) || + _hasArrayMemberConsumer(output, assignmentsBySource))) { + continue; + } + + replacements.addAll(producerReplacements); + consumedAssignments + ..addAll(swizzle.inputAssignments) + ..addAll(mappedProducerAssignments); + outputMappingReplacements.addAll(producerOutputMappingReplacements); + intermediate.clearDeclaration(); + swizzle.inst.clearInstantiation(); + } + + if (replacements.isNotEmpty || outputMappingReplacements.isNotEmpty) { + for (final replacement in outputMappingReplacements) { + replacement.instantiation.setOutputMapping( + replacement.portName, + replacement.reference, + replace: true, + ); + } + final updatedAssignments = [ + for (final assignment in assignments) + if (replacements.containsKey(assignment)) + replacements[assignment]! + else if (!consumedAssignments.contains(assignment)) + assignment, + ]; + assignments + ..clear() + ..addAll(updatedAssignments); + } + } + + /// Whether [source] drives any array member assignment. + bool _hasArrayMemberConsumer( + SynthLogic source, + Map> assignmentsBySource, + ) { + final consumers = assignmentsBySource[source] ?? const []; + return consumers.any( + (assignment) => + assignment.dst is SynthLogicArrayElement || + assignment.dst.resolved.logics.any((logic) => logic.isArrayMember), + ); + } + + /// Resolves [range] through clearable full-width drivers and + /// [knownSourceRanges], retaining the original relative selection. + _SynthRangeRef _resolveKnownRangeThroughFullWidthDrivers( + _SynthRangeRef range, + Map> assignmentsByDestination, + Map knownSourceRanges, [ + Set visiting = const {}, + ]) { + if (visiting.contains(range.base) || !range.base.isClearable) { + return range; + } + + final driver = _singleFullWidthAssignment( + range.base, + assignmentsByDestination, + ); + if (driver == null) { + return range; + } + + final sourceRange = driver is RangeSynthAssignment + ? _SynthRangeRef( + driver.src.resolved, + driver.srcLowerIndex, + driver.srcUpperIndex, + ) + : knownSourceRanges[driver.src.resolved] ?? + _SynthRangeRef(driver.src.resolved, 0, driver.src.width - 1); + + final resolvedSourceRange = _resolveKnownRangeThroughFullWidthDrivers( + sourceRange, + assignmentsByDestination, + knownSourceRanges, + {...visiting, range.base}, + ); + if (resolvedSourceRange.width != range.base.width) { + return range; + } + + return _SynthRangeRef.tryCreate( + resolvedSourceRange.base, + resolvedSourceRange.lower + range.lower, + resolvedSourceRange.lower + range.upper, + ) ?? + range; + } + + /// Finds unnamed generated-subset candidates and the subset intermediates + /// that feed a live full-width swizzle consumer. + ({Set candidates, Set intermediates}) + _generatedSubsetIntermediateSets() { + final assignmentsByDestination = + _assignmentsBy(assignments, (assignment) => assignment.dst.resolved); + final assignmentsBySourceBase = _assignmentsBy( + assignments, (assignment) => _referenceBase(assignment.src)); + + final swizzleSourceRanges = + _fullPackedSwizzleSourceRanges(assignmentsByDestination); + final candidates = { + for (final entry in swizzleSourceRanges.entries) + if (_isGeneratedSubsetIntermediateCandidate(entry.value.range.base)) + entry.value.range.base, + }; + + return ( + candidates: candidates, + intermediates: _generatedSubsetIntermediatesFrom( + swizzleSourceRanges, + assignmentsBySourceBase, + ), + ); + } + + /// Finds generated subset intermediates eligible for range composition. + Set _generatedSubsetIntermediates() => + _generatedSubsetIntermediateSets().intermediates; + + /// Filters [swizzleSourceRanges] to generated subset intermediates with a + /// live full-width internal consumer. + Set _generatedSubsetIntermediatesFrom( + Map< + SynthLogic, + ({ + _SynthRangeRef range, + SynthSubModuleInstantiation inst, + List inputAssignments, + })> + swizzleSourceRanges, + Map> assignmentsBySourceBase, + ) { + final generatedSubsetIntermediates = {}; + for (final entry in swizzleSourceRanges.entries) { + final intermediate = entry.value.range.base; + if (_isGeneratedSubsetIntermediateCandidate(intermediate) && + _hasInternalFullWidthSwizzleConsumer( + entry.key, + assignmentsBySourceBase, + )) { + generatedSubsetIntermediates.add(intermediate); + } + } + + return generatedSubsetIntermediates; + } + + /// Maps each non-net [Swizzle] output that reconstructs a complete packed + /// array to its source range, helper, and any full-width input assignments. + Map< + SynthLogic, + ({ + _SynthRangeRef range, + SynthSubModuleInstantiation inst, + List inputAssignments, + })> _fullPackedSwizzleSourceRanges( + Map> assignmentsByDestination, + ) { + final ranges = inputAssignments, + })>{}; + + for (final instantiation in subModuleInstantiations) { + final module = instantiation.module; + if (module is! Swizzle || module.isNet) { + continue; + } + + final output = instantiation.outputMapping[module.resultSignalName]; + if (output == null) { + continue; + } + + final indexedInputs = <({int index, SynthLogic signal})>[]; + final inputAssignments = []; + var hasUnindexedInput = false; + for (final entry in instantiation.inputMapping.entries) { + final index = _swizzleInputIndex(entry.key); + if (index == null) { + hasUnindexedInput = true; + break; + } + final inputDriver = _singleFullWidthAssignment( + entry.value.resolved, + assignmentsByDestination, + ); + if (inputDriver != null) { + inputAssignments.add(inputDriver); + } + indexedInputs.add(( + index: index, + signal: inputDriver?.src.resolved ?? entry.value.resolved, + )); + } + if (hasUnindexedInput || indexedInputs.isEmpty) { + continue; + } + + indexedInputs.sort((a, b) => a.index.compareTo(b.index)); + SynthLogic? parentArray; + var allInputsMatch = true; + for (final (expectedIndex, input) in indexedInputs.indexed) { + if (input.index != expectedIndex || + input.signal is! SynthLogicArrayElement) { + allInputsMatch = false; + break; + } + + final element = input.signal as SynthLogicArrayElement; + final array = element.parentArray.resolved; + parentArray ??= array; + if (array != parentArray || + !_canUsePackedRangeBase(array) || + element.logic.arrayIndex != expectedIndex) { + allInputsMatch = false; + break; + } + } + + if (!allInputsMatch || parentArray == null) { + continue; + } + + final arrayLogic = parentArray.logics.first; + if (arrayLogic is! LogicArray || + indexedInputs.length != arrayLogic.elements.length) { + continue; + } + + ranges[output.resolved] = ( + range: _SynthRangeRef(parentArray, 0, indexedInputs.length - 1), + inst: instantiation, + inputAssignments: inputAssignments, + ); + } + + return ranges; + } + + /// Parses the numeric index from a [Swizzle] input named `inN`. + int? _swizzleInputIndex(String portName) { + final match = RegExp(r'in(\d+)$').firstMatch(portName); + return match == null ? null : int.parse(match.group(1)!); + } + + /// Whether [intermediate] is a disposable non-net signal that can be removed + /// from a range chain without crossing a port or unpacked-array boundary. + bool _isRangeChainIntermediate( + SynthLogic intermediate, { + SynthSubModuleInstantiation? allowedInstantiation, + Set? mappedIntermediates, + }) { + if (!internalSignals.contains(intermediate) || + intermediate.isNet || + intermediate.isConstant || + !intermediate.isClearable || + intermediate.isPort(module) || + intermediate.isStructPortElement(module)) { + return false; + } + + if (mappedIntermediates?.contains(intermediate) ?? false) { + return false; + } + + for (final instantiation in mappedIntermediates == null + ? subModuleInstantiations + : const []) { + if (instantiation == allowedInstantiation) { + continue; + } + final mappedSignals = [ + ...instantiation.inputMapping.values, + ...instantiation.outputMapping.values, + ...instantiation.inOutMapping.values, + ]; + if (mappedSignals.any((signal) => + signal.resolved == intermediate || + (signal is SynthLogicArrayElement && + signal.parentArray.resolved == intermediate))) { + return false; + } + } + + final logic = intermediate.logics.firstOrNull; + return logic is! LogicArray || logic.numUnpackedDimensions == 0; + } + + /// Whether [intermediate] has the unnamed packed-array shape generated by + /// `assignSubset`. + bool _isGeneratedSubsetIntermediateCandidate(SynthLogic intermediate) { + if (!internalSignals.contains(intermediate) || + !intermediate.isClearable || + intermediate.isPort(module) || + intermediate.isStructPortElement(module) || + !_canUsePackedRangeBase(intermediate)) { + return false; + } + + final arrayLogic = intermediate.logics.singleOrNull; + return arrayLogic is LogicArray && arrayLogic.naming == Naming.unnamed; + } + + /// Whether [swizzleOutput] feeds a full-width disposable internal signal. + bool _hasInternalFullWidthSwizzleConsumer( + SynthLogic swizzleOutput, + Map> assignmentsBySourceBase, + ) { + final consumers = assignmentsBySourceBase[swizzleOutput] ?? const []; + return consumers.any((assignment) { + if (assignment is PartialSynthAssignment || + assignment.width != swizzleOutput.width) { + return false; + } + + final dst = assignment.dst.resolved; + if (!internalSignals.contains(dst) || + dst.isNet || + dst.isConstant || + dst.isPort(module) || + dst.isStructPortElement(module) || + dst.logics.any((logic) => logic.isArrayMember) || + dst.width != swizzleOutput.width) { + return false; + } + + return dst.logics.singleOrNull is! LogicArray; + }); + } + + /// Returns the packed source range represented by [assignment]. + _SynthRangeRef _assignmentSourceRange(SynthAssignment assignment) { + if (assignment is RangeSynthAssignment) { + return _SynthRangeRef( + assignment.src.resolved, + assignment.srcLowerIndex, + assignment.srcUpperIndex, + ); + } + + final arrayElementRange = _arrayElementRange(assignment.src.resolved); + if (arrayElementRange != null) { + return arrayElementRange; + } + + return _SynthRangeRef( + assignment.src.resolved, + 0, + assignment.src.width - 1, + ); + } + + /// Returns the packed destination range represented by [assignment]. + _SynthRangeRef _assignmentDestinationRange(SynthAssignment assignment) { + if (assignment is PartialSynthAssignment) { + return _SynthRangeRef( + assignment.dst.resolved, + assignment.dstLowerIndex, + assignment.dstUpperIndex, + ); + } + + final arrayElementRange = _arrayElementRange(assignment.dst.resolved); + if (arrayElementRange != null) { + return arrayElementRange; + } + + return _SynthRangeRef( + assignment.dst.resolved, + 0, + assignment.dst.width - 1, + ); + } + + /// Returns the packed object referenced by [signal] for grouping and usage + /// comparisons. + SynthLogic _referenceBase(SynthLogic signal) => switch (signal) { + SynthLogicArrayElement() => signal.parentArray.resolved, + SynthLogicPackedBitReference() => signal.packedBase.resolved, + _ => signal.resolved, + }; + + /// Returns the outermost array containing [signal], or [signal] itself when + /// it is not an array element. + SynthLogic _arrayRoot(SynthLogic signal) { + var root = signal.resolved; + while (root is SynthLogicArrayElement) { + root = root.parentArray.resolved; + } + return root; + } + /// Collapses assignments that don't need to remain present. void _collapseAssignments() { // there might be more assign statements than necessary, so let's ditch them diff --git a/lib/src/utilities/simcompare.dart b/lib/src/utilities/simcompare.dart index d7850df4e..f8c07eb71 100644 --- a/lib/src/utilities/simcompare.dart +++ b/lib/src/utilities/simcompare.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // simcompare.dart @@ -240,6 +240,8 @@ abstract class SimCompare { bool maskKnownWarnings = true, bool enableChecking = true, bool buildOnly = false, + SystemVerilogSynthesizerConfiguration synthesizerConfiguration = + const SystemVerilogSynthesizerConfiguration(), }) { final result = iverilogVector(module, vectors, moduleName: moduleName, @@ -248,7 +250,8 @@ abstract class SimCompare { iverilogExtraArgs: iverilogExtraArgs, allowWarnings: allowWarnings, maskKnownWarnings: maskKnownWarnings, - buildOnly: buildOnly); + buildOnly: buildOnly, + synthesizerConfiguration: synthesizerConfiguration); if (enableChecking) { expect(result, true); } @@ -265,6 +268,8 @@ abstract class SimCompare { bool allowWarnings = false, bool maskKnownWarnings = true, bool buildOnly = false, + SystemVerilogSynthesizerConfiguration synthesizerConfiguration = + const SystemVerilogSynthesizerConfiguration(), }) { if (kIsWeb) { // if running in web mode, then we can't run icarus verilog @@ -342,7 +347,9 @@ abstract class SimCompare { allSignals.map((e) => '.$e(${logicToWireMapping[e] ?? e})').join(', '); final moduleInstance = '$topModule dut($moduleConnections);'; final stimulus = vectors.map((e) => e.toTbVerilog(module)).join('\n'); - final generatedVerilog = module.generateSynth(); + final generatedVerilog = module.generateSynth( + configuration: synthesizerConfiguration, + ); // so that when they run in parallel, they dont step on each other final uniqueId = diff --git a/packages/rohd_hierarchy/LICENSE b/packages/rohd_hierarchy/LICENSE new file mode 100644 index 000000000..cfbbee995 --- /dev/null +++ b/packages/rohd_hierarchy/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (C) 2021-2023 Intel Corporation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rohd_hierarchy/README.md b/packages/rohd_hierarchy/README.md new file mode 100644 index 000000000..f99e9cc88 --- /dev/null +++ b/packages/rohd_hierarchy/README.md @@ -0,0 +1,246 @@ +# rohd_hierarchy + +An incremental design dictionary for hardware module hierarchies. + +## Motivation + +A remote agent — a debugger, a waveform viewer, a schematic renderer, an +AI assistant — needs to understand the structure of a hardware design in order +to ask useful questions about it. Transferring the full design every time is +wasteful. What both sides of a link really need is a shared **dictionary** of +the design: the modules, occurrences, and signals that make it up, plus +a compact way to refer to any object by address. + +Once both sides share the same dictionary, communication becomes cheap: +either side can request data about a specific object by its address alone, +without re-transmitting structural context. + +### What is a design dictionary? + +A design dictionary captures the **hierarchy and connectivity** of a +hardware design: + +- **Occurrences** — unfolded module instances in the hierarchy tree. + Each has a `name`, an optional `definition` (the + module type), child occurrences, and signals. +- **Signals** — named wires within an occurrence. Each has a `name`, + `width`, optional `direction` (input/output/inout), and optional + `value`. + +In the occurrence view of a design, every `Logic` wire and every module +instance is unique. Both are occurrences: either signal/logic occurrences +or module occurrences. + +The full "unfolded" view of a design is its **address space**: every +occurrence and every signal reachable by walking the hierarchy tree. + +### Compact, canonical addressing + +`rohd_hierarchy` assigns each object a **canonical address** — a short +sequence of child indices (e.g. `0.2.4`) that uniquely identifies it within +the tree. + +Addresses are **relative within each occurrence**: an occurrence's address +table maps local indices to its children and signals without relying on any +global namespace. This locality property is what makes the dictionary +**incrementally expandable** — a remote agent can: + +1. Request the top-level dictionary table (the root occurrence's children + and signals). +2. Drill into any child by requesting that child's dictionary table. +3. Continue expanding only the parts of the hierarchy it actually needs. + +At each step, both sides agree on the addresses, so subsequent data +requests (waveform samples, signal values, schematic fragments) carry +only the compact address, not the full path or structural description. + +## Package overview + +`rohd_hierarchy` is a source-agnostic Dart package that implements this +dictionary model. It provides data models, search utilities, and adapter +interfaces that work independently of any particular HDL toolchain or +transport layer. + +### Data models + +- **`HierarchyOccurrence`** — An occurrence of a module instance in the + unfolded hierarchy tree, with children, signals, name, an optional + `definition` (module type), and a primitive flag. Call `buildAddresses()` + to assign a canonical `OccurrenceAddress` to every occurrence and signal + in O(n). Use `signalCount` and `computedSignalCount` for efficient + subtree counts. +- **`OccurrenceAddress`** — An immutable, index-based path through the + tree (e.g. `[0, 2, 4]`). Supports conversion to/from dot-separated + strings. Works as an O(1) cache key. +- **`SignalOccurrence`** — Signal metadata: name, width, optional + direction, and optional value. Signals with a `direction` serve as + ports (input, output, inout). + +### Services & adapters + +- **`HierarchyService`** — A mixin providing tree-walking search and + navigation: `searchSignals()`, `searchOccurrences()`, + `autocompletePaths()`, regex/glob search (`searchSignalsRegex()`, + `searchOccurrencesRegex()`), and address↔pathname conversion. +- **`BaseHierarchyAdapter`** — An abstract class wrapping a + `HierarchyOccurrence` tree with `HierarchyService`. Use + `BaseHierarchyAdapter.fromTree()` to wrap an existing tree. +- **`NetlistHierarchyAdapter`** — A concrete adapter that parses netlist + JSON into a `HierarchyOccurrence` tree. + +### Search queries + +- **`HierarchyQuery`** — Abstract base class for pluggable search + strategies. The matching logic is decoupled from tree traversal. +- **`PrefixQuery`** — Prefix-substring matching. Segments split on `/` + or `.` are matched case-sensitively via `startsWith` (signals) or + `contains` (occurrences). Created via `HierarchyQuery.prefix()`. +- **`RegexQuery`** — Regex/glob matching. Each segment is compiled as a + regex. Supports `*` (any chars), `?` (one char), `**` (zero or more + hierarchy levels), character classes (`[0-9]`), alternation + (`(clk|reset)`), and quantifiers. Created via `HierarchyQuery.regex()`. + +### Search controller + +- **`HierarchySearchController`** — A pure-Dart controller for + keyboard-navigable search result lists, with `updateQuery()`, + `selectNext()` / `selectPrevious()`, `tabComplete()`, and scroll-offset + helpers. Factories `forSignals()` and `forOccurrences()` cover the + common cases. + +## Usage + +### Building a dictionary from a netlist + +```dart +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +final dict = NetlistHierarchyAdapter.fromJson(netlistJsonString); +final root = dict.root; // the top-level dictionary table +``` + +### Wrapping an existing tree + +When you already have a `HierarchyOccurrence` tree (e.g. from a VCD +parser, a ROHD simulation, or any other source), wrap it to gain search +and address resolution: + +```dart +final dict = BaseHierarchyAdapter.fromTree(rootNode); +``` + +### Incremental expansion by a remote agent + +A remote agent does not need the full tree up front. It can expand the +dictionary one level at a time: + +```dart +// Agent receives the root table +final root = dict.root; + +// Agent picks a child to expand (e.g. child 2) +final child = root.children[2]; + +// The child's own children and signals are its local dictionary table. +// The agent now knows addresses 2.0, 2.1, ... for that subtree. +``` + +### Compact address-based communication + +Once both sides share the dictionary, data requests use addresses only: + +```dart +// Resolve a human-readable pathname to a canonical address +final addr = dict.pathnameToAddress('Counter/clk'); + +// Send the compact address over the wire: "0.1" +final wire = addr!.toDotString(); + +// The other side resolves it back +final resolved = dict.occurrenceByAddress(OccurrenceAddress.fromDotString(wire)); +final pathname = dict.addressToPathname(addr!); +``` + +### Searching the dictionary + +#### Prefix search (default) + +Segments are split on `/` or `.` and matched as case-sensitive substrings: + +```dart +// Find all signals whose path contains 'cpu' then 'clk' +final signals = dict.searchSignals('cpu/clk'); + +// Find occurrences containing 'counter' +final modules = dict.searchOccurrences('counter'); + +// Tab-completion for partial paths +final completions = dict.autocompletePaths('Top/CPU/'); +``` + +#### Regex / glob search + +Each segment is a regex anchored to the full name. Glob wildcards `*` +and `?` are auto-converted. Use `**` to match across hierarchy levels: + +```dart +// All 'clk' signals anywhere in the design +final clocks = dict.searchSignalsRegex('Top/**/clk'); + +// Signals named d0–d15 in any regfile +final data = dict.searchSignalsRegex('Top/**/regfile/d[0-9]+'); + +// Either 'clk' or 'reset' anywhere +final resets = dict.searchSignalsRegex('Top/**/(clk|reset)'); + +// All cache channels ch0–ch2 +final channels = dict.searchOccurrencesRegex('Top/mem_ctrl/ch[0-2]'); + +// Signals containing 'mux' in their name +final muxed = dict.searchSignalsRegex('Top/**/.*mux.*'); + +// All signals in a specific module +final all = dict.searchSignalsRegex('Top/CPU/ALU/*'); +``` + +### Constructing occurrences manually + +```dart +final root = HierarchyOccurrence( + name: 'Counter', + definition: 'Counter', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'count', width: 8, direction: 'output'), + ], + children: [ + HierarchyOccurrence( + name: 'adder', + definition: 'Adder', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'sum', width: 8), + ], + ), + ], +); + +// Assign canonical addresses +root.buildAddresses(); + +// Now every occurrence and signal has an address +print(root.children.first.path()); // 'Counter/adder' +print(root.signals.first.path()); // 'Counter/clk' +``` + +## Design principles + +| Principle | How it is achieved | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Source-agnostic** | The data model is independent of any HDL toolchain. `NetlistHierarchyAdapter` handles netlist JSON; `BaseHierarchyAdapter.fromTree()` wraps any tree. | +| **Incremental** | Addresses are relative within each occurrence. A remote agent expands only the subtrees it needs, one dictionary table at a time. | +| **Compact** | `OccurrenceAddress` is a short index path (e.g. `0.2.4`), not a full dotted pathname. Both sides resolve it locally. | +| **Canonical** | `buildAddresses()` assigns deterministic indices in tree order. The same design always produces the same addresses. | +| **No global namespace** | Each occurrence's address table is self-contained. Adding or removing a sibling subtree does not invalidate addresses in unrelated parts of the tree. | +| **Transport-independent** | The package defines the dictionary model, not the wire protocol. Any transport (VM service, JSON-RPC, gRPC, WebSocket) can carry the compact addresses. | diff --git a/packages/rohd_hierarchy/analysis_options.yaml b/packages/rohd_hierarchy/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/rohd_hierarchy/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/rohd_hierarchy/lib/rohd_hierarchy.dart b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart new file mode 100644 index 000000000..86ed7c336 --- /dev/null +++ b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_hierarchy.dart +// Main library export for rohd_hierarchy package. +// +// 2026 January +// Author: Desmond Kirkpatrick + +/// Generic hierarchy data models for hardware module navigation. +/// +/// This library provides source-agnostic data models for representing +/// hardware module hierarchies: +/// +/// ## Core Data Models +/// - `OccurrenceAddress` - Efficient index-based addressing for tree navigation +/// - `HierarchyOccurrence` - An occurrence of a module definition in the tree +/// - `SignalOccurrence` - A signal in the hierarchy +/// +/// ## Search & Navigation +/// - `SignalSearchResult` - Result of a signal search with enriched metadata +/// - `OccurrenceSearchResult` - Result of an occurrence search with metadata +/// - `HierarchyService` - Abstract interface for hierarchy navigation +/// - `HierarchySearchController` - Pure Dart search state controller +/// +/// ## Adapters +/// - `BaseHierarchyAdapter` - Base class with shared adapter implementation +/// - `NetlistHierarchyAdapter` - Adapter for netlist JSON format +/// +/// This package has no dependencies and can be used standalone by any +/// application that needs to navigate hardware hierarchies. +/// +/// ## Quick Start +/// ```dart +/// 1. Create hierarchy +/// final root = HierarchyOccurrence(id: 'top', name: 'top'); +/// root.buildAddresses(); // Enable address-based navigation +/// +/// 2. Search +/// final service = BaseHierarchyAdapter.fromTree(root); +/// final results = service.searchSignals('clk'); +/// ``` +library; + +export 'src/base_hierarchy_adapter.dart'; +export 'src/hierarchy_models.dart'; +export 'src/hierarchy_query.dart'; +export 'src/hierarchy_search_controller.dart'; +export 'src/hierarchy_service.dart'; +export 'src/netlist_hierarchy_adapter.dart'; diff --git a/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart new file mode 100644 index 000000000..772007497 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// base_hierarchy_adapter.dart +// Base class with shared implementation for hierarchy adapters. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; + +/// Base class providing shared implementation for hierarchy adapters. +/// +/// The [HierarchyOccurrence] tree rooted at [root] is the single source of +/// truth. Children and signals are read directly from each occurrence's +/// [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] lists. +/// Lookups use [OccurrenceAddress]-based navigation. +/// +/// Concrete adapters should: +/// 1. Extend this class +/// 2. Build a complete [HierarchyOccurrence] tree (with children and signals +/// populated on each occurrence) +/// 3. Set the [root] occurrence +/// +/// Search, autocomplete, and signal lookup are implemented by +/// [HierarchyService] via recursive tree walking. +abstract class BaseHierarchyAdapter with HierarchyService { + HierarchyOccurrence? _root; + + /// Creates a [BaseHierarchyAdapter]. + BaseHierarchyAdapter(); + + /// Creates an adapter wrapping an existing [HierarchyOccurrence] tree. + /// + /// The tree itself is the single source of truth — children and signals + /// are read directly from the [HierarchyOccurrence] lists. + /// + /// Example usage: + /// ```dart + /// final treeRoot = await dataSource.evalModuleTree(); + /// final service = BaseHierarchyAdapter.fromTree(treeRoot); + /// final paths = service.searchSignalPaths('clk'); + /// ``` + factory BaseHierarchyAdapter.fromTree( + HierarchyOccurrence rootNode, + ) = _TreeBackedAdapter; + + /// Sets the root occurrence. Call this once during initialisation. + set root(HierarchyOccurrence node) { + _root = node; + } + + // ───────────────────────────────────────────────────────────────────────── + // HierarchyService concrete accessors — all tree-walking, no flat maps + // ───────────────────────────────────────────────────────────────────────── + + @override + HierarchyOccurrence get root { + if (_root == null) { + throw StateError( + 'Root occurrence not set. Call setRoot() during initialization.'); + } + return _root!; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tree-backed implementation returned by BaseHierarchyAdapter.fromTree() +// ───────────────────────────────────────────────────────────────────────────── + +/// Private adapter that wraps an existing [HierarchyOccurrence] tree. +/// +/// Children and signals are read directly from the tree occurrences. +class _TreeBackedAdapter extends BaseHierarchyAdapter { + _TreeBackedAdapter(HierarchyOccurrence rootNode) { + root = rootNode; + rootNode.buildAddresses(); + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_constants.dart b/packages/rohd_hierarchy/lib/src/hierarchy_constants.dart new file mode 100644 index 000000000..afb8b8722 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_constants.dart @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_constants.dart +// Shared constants for hierarchy path formatting and parsing. +// +// 2026 July +// Author: Desmond Kirkpatrick + +/// Canonical separator used in hierarchy paths returned by this package. +const String hierarchyPathSeparator = '/'; + +/// Default maximum number of results returned by hierarchy search methods. +const int defaultHierarchySearchLimit = 100; diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_models.dart b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart new file mode 100644 index 000000000..2f7cb3f76 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_models.dart +// Barrel file re-exporting all hierarchy data model classes. +// +// 2026 January +// Author: Desmond Kirkpatrick + +export 'hierarchy_constants.dart'; +export 'hierarchy_occurrence.dart'; +export 'hierarchy_search_result.dart'; +export 'occurrence_address.dart'; +export 'occurrence_search_result.dart'; +export 'signal_occurrence.dart'; +export 'signal_search_result.dart'; diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart new file mode 100644 index 000000000..06fbf28f7 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart @@ -0,0 +1,258 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_occurrence.dart +// An occurrence of a module definition in the unfolded hierarchy tree. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// An occurrence of a module definition in the unfolded hierarchy tree. +/// +/// This is the core structural data model, independent of waveform data. +/// Path strings are computed on demand from parent references rather than +/// stored — call [path] with your desired separator. +class HierarchyOccurrence { + /// Display name of this occurrence (instance name within its parent). + final String name; + + /// Definition (module) name for this occurrence. + final String? definition; + + /// Whether this occurrence is a primitive cell (gate, operator, register, + /// etc.) whose internal structure is not useful for design navigation. + /// + /// Set by the parser/adapter that creates the occurrence. The netlist + /// adapter sets this for cells that lack a module definition in the JSON or + /// whose definition starts with `$` (netlist built-in primitives). + /// Tool-specific primitives (e.g. ROHD's FlipFlop → `$dff`) are handled by + /// the synthesizer mapping them to `$`-prefixed definitions before the JSON + /// is written. + final bool isPrimitive; + + /// Signals within this occurrence (includes both internal signals and + /// ports). Empty for leaf occurrences. + final List signals; + + /// Child occurrences. Populated from sub-modules in the hierarchy. + final List children; + + /// Hierarchical address for this occurrence. + /// Assigned by [buildAddresses] to enable efficient navigation. + /// Format: [child0, child1, ..., childN] for nested occurrences. + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Parent occurrence, or `null` for the root. + /// Set by [buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Creates a [HierarchyOccurrence] with the given properties. + HierarchyOccurrence({ + required this.name, + this.definition, + this.isPrimitive = false, + List? signals, + List? children, + }) : signals = signals ?? [], + children = children ?? []; + + /// Compute the full hierarchical path by walking up the parent chain. + /// + /// Uses [separator] between path segments (default `/`). + /// Returns just [name] for the root (no parent). + String path({String separator = hierarchyPathSeparator}) { + if (_parent == null) { + return name; + } + final parts = []; + HierarchyOccurrence? cur = this; + while (cur != null) { + parts.add(cur.name); + cur = cur._parent; + } + return parts.reversed.join(separator); + } + + /// Returns only signals that are ports (have a direction). + List get ports => signals.where((s) => s.isPort).toList(); + + // ───────────────── Name → offset (index) lookups ───────────────── + + /// Lazily-built index: child name → offset in [children]. + Map? _childNameIndex; + + /// Lazily-built index: signal name → offset in [signals]. + Map? _signalNameIndex; + + /// Return the offset (index) of the child with [name] in [children], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int childIndexByName(String name) { + _childNameIndex ??= { + for (var i = 0; i < children.length; i++) children[i].name: i, + }; + return _childNameIndex![name] ?? -1; + } + + /// Return the offset (index) of the signal with [name] in [signals], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int signalIndexByName(String name) { + _signalNameIndex ??= { + for (var i = 0; i < signals.length; i++) signals[i].name: i, + }; + return _signalNameIndex![name] ?? -1; + } + + /// Whether [cellType] represents a netlist built-in primitive cell type. + /// + /// Returns `true` for `$`-prefixed types (`$mux`, `$dff`, `$and`, etc.) + /// which are netlist built-in operators and primitives. + /// + /// Tool-specific primitive types (e.g. ROHD's `FlipFlop`) should be + /// handled by the producer: the synthesizer should map them to + /// `$`-prefixed cell types in the JSON output, or the adapter should + /// set [isPrimitive] on the occurrence at construction time. + /// + /// Use this before a [HierarchyOccurrence] exists (e.g. when deciding + /// whether to recurse into a netlist cell definition). For an existing + /// occurrence, use the getter [isPrimitiveCell] instead. + static bool isPrimitiveType(String cellType) => cellType.startsWith(r'$'); + + /// Whether this occurrence represents a primitive cell that should be hidden + /// from the occurrence tree. + /// + /// Checks the [isPrimitive] field (set by the adapter at construction time) + /// and falls back to [isPrimitiveType] on the occurrence's [definition]. + bool get isPrimitiveCell => + isPrimitive || (definition != null && isPrimitiveType(definition!)); + + /// Returns only input signals. + List get inputs => + signals.where((s) => s.direction == 'input').toList(); + + /// Returns only output signals. + List get outputs => + signals.where((s) => s.direction == 'output').toList(); + + /// Returns only inout signals. + List get inouts => + signals.where((s) => s.direction == 'inout').toList(); + + /// Number of port signals in this occurrence. + int get portCount => signals.where((s) => s.isPort).length; + + /// Finds the sub-field [SignalOccurrence] entries for a struct/array signal. + /// + /// Given a `parentSignal` that has `logicType` metadata (struct fields or + /// array dims), looks up the expected sub-field signal names in this + /// occurrence's signal list using the Namer/Sanitizer naming convention: + /// `{parentSignalName}_{fieldName}` + /// + /// Returns a list of resolved sub-field signals in field order. + /// Entries may be null if a particular sub-field signal wasn't found + /// (e.g. the netlist didn't emit it, or it was optimized away). + List<({SignalOccurrence? signal, String fieldLabel, int width, int startBit})> + findSubFieldSignals(SignalOccurrence parentSignal) { + final descriptors = parentSignal.subFieldDescriptors; + if (descriptors.isEmpty) { + return const []; + } + + return descriptors.map((d) { + final idx = signalIndexByName(d.expectedName); + final sig = idx >= 0 ? signals[idx] : null; + return ( + signal: sig, + fieldLabel: d.fieldLabel, + width: d.width, + startBit: d.startBit, + ); + }).toList(); + } + + /// Collect all signals under this occurrence in depth-first order. + /// + /// Visits this occurrence's [signals] first, then recurses into + /// [children] in order. Useful for flat iteration or counting, but + /// signals should always be identified by their [OccurrenceAddress] or + /// path — never by a positional index in this list. + /// + /// Production code should use [signalCount], [computedSignalCount], or + /// a recursive visitor instead of materializing the full list. + @visibleForTesting + List depthFirstSignals() => + [...signals, ...children.expand((c) => c.depthFirstSignals())]; + + /// Total number of signals in this subtree (O(n) recursive count). + /// + /// Equivalent to `depthFirstSignals().length` but avoids allocating the + /// intermediate list. + int get signalCount => + signals.length + children.fold(0, (sum, c) => sum + c.signalCount); + + /// Number of computed signals in this subtree. + /// + /// Equivalent to + /// `depthFirstSignals().where((s) => s.isComputed).length` + /// but avoids allocating the intermediate list. + int get computedSignalCount => + signals.where((s) => s.isComputed).length + + children.fold(0, (sum, c) => sum + c.computedSignalCount); + + /// Build hierarchical addresses for this occurrence and all descendants. + /// + /// This performs a single O(n) tree traversal to assign [OccurrenceAddress] + /// to every occurrence and signal in the tree. Call this once after tree + /// construction to enable efficient address-based navigation. + /// + /// **Signal address ordering**: ports (signals with a non-null + /// [SignalOccurrence.direction]) are assigned indices first + /// (`0 .. portCount-1`), followed by internal signals + /// (`portCount .. signals.length-1`). Within each group the + /// original list order is preserved. + /// + /// This means a port's [SignalOccurrence.portIndex] always equals its + /// signal address index, which consumers (e.g. schematic hyperedges) can + /// rely on remaining stable across incremental expansion. + /// + /// Example: + /// ```dart + /// root.buildAddresses(); // Assign addresses to all occurrences/signals + /// final signalAddr = signals[0].address; // Now available + /// ``` + void buildAddresses([OccurrenceAddress startAddr = OccurrenceAddress.root]) { + _address = startAddr; + + // Assign ports first, then internal signals, so that port indices + // are stable across incremental hierarchy expansion. + var idx = 0; + for (final s in signals) { + if (s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + for (final s in signals) { + if (!s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + + for (final (i, c) in children.indexed) { + c + .._parent = this + ..buildAddresses(startAddr.child(i)); + } + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_query.dart b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart new file mode 100644 index 000000000..c350a2391 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart @@ -0,0 +1,152 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query.dart +// Pluggable search query abstraction for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; +import 'package:rohd_hierarchy/src/prefix_query.dart'; +import 'package:rohd_hierarchy/src/regex_query.dart'; + +// Re-export so callers importing hierarchy_query.dart get the concrete types. +export 'package:rohd_hierarchy/src/prefix_query.dart'; +export 'package:rohd_hierarchy/src/regex_query.dart'; + +/// What kind of hierarchy elements a query should match. +enum SearchTarget { + /// Match only [HierarchyOccurrence] nodes (modules, instances). + occurrences, + + /// Match only signals within occurrences. + signals, + + /// Match both occurrences and signals. + both, +} + +/// Abstract base class for hierarchy search queries. +/// +/// A [HierarchyQuery] encapsulates the *matching strategy* (how names are +/// compared) independently of the *tree traversal* (which is always +/// performed by [HierarchyService]). +/// +/// ## Contract with [HierarchyService] +/// +/// The service walks the [HierarchyOccurrence] tree depth-first. +/// At each node it calls: +/// +/// 1. [matchOccurrence] — does this occurrence name satisfy the query at the +/// current match state? Returns a set of successor states (empty = +/// prune this branch). +/// 2. [matchSignal] — does this signal name satisfy the query at the +/// current match state? +/// 3. [isComplete] — have all parts of the query been consumed at the +/// given state? +/// +/// "Match state" is an opaque integer that the query owns. It typically +/// tracks how many segments/tokens of the query have been consumed so far. +/// The initial state is always `0`. +/// +/// ## Crossing hierarchy boundaries +/// +/// If [crossesBoundaries] is true the service will, at each depth, +/// additionally try advancing with the *current* state even when the +/// occurrence doesn't match — allowing matches to span across +/// intermediate hierarchy levels (like `**` in glob patterns). +/// +/// ## Subclassing +/// +/// Implement a concrete query by overriding at least [matchOccurrence], +/// [matchSignal], [isComplete], and [segmentCount]. +/// +/// The factory [HierarchyQuery.prefix] creates the default +/// prefix-substring query. [HierarchyQuery.regex] creates a +/// regex/glob query. +/// +/// ```dart +/// // Custom fuzzy query +/// class FuzzyQuery extends HierarchyQuery { +/// FuzzyQuery(String rawQuery) +/// : super(rawQuery, target: SearchTarget.signals); +/// ... +/// } +/// ``` +abstract class HierarchyQuery { + /// The original user-supplied query string. + final String rawQuery; + + /// What this query matches — occurrences, signals, or both. + final SearchTarget target; + + /// Whether this query can match across hierarchy boundaries. + /// + /// When true, the tree walker will try the current match state at + /// deeper levels even when intermediate occurrences don't match. + /// Conceptually equivalent to an implicit `**` between segments. + final bool crossesBoundaries; + + /// Creates a query from [rawQuery]. + /// + /// Subclasses should parse/compile the query in their constructor. + const HierarchyQuery( + this.rawQuery, { + this.target = SearchTarget.signals, + this.crossesBoundaries = false, + }); + + /// Number of logical segments in the parsed query. + /// + /// Used by the tree walker to know when the query is fully consumed. + int get segmentCount; + + /// Whether the query is empty / trivial (should return no results). + bool get isEmpty => rawQuery.trim().isEmpty; + + /// Try matching an occurrence name at match state [stateIndex]. + /// + /// Returns a set of successor states. Multiple successors arise when + /// the query is ambiguous at this point (e.g. a glob-star `**` can + /// consume zero or more levels). + /// + /// An empty set means "no match — prune this subtree". + Set matchOccurrence(String occurrenceName, int stateIndex); + + /// Whether [signalName] matches the query at state [stateIndex]. + /// + /// Only called when [target] includes signals. + bool matchSignal(String signalName, int stateIndex); + + /// Whether the query is fully consumed at [stateIndex]. + /// + /// Returns true when all segments have been matched and the current + /// tree position is a valid result. + bool isComplete(int stateIndex); + + // ──────────────── Built-in query factories ──────────────── + + /// Create a **prefix-substring** query. + /// + /// The query is split on `/` or `.` into segments. Each segment is + /// matched via `startsWith` (for signals) or + /// `contains` (for occurrences) against names at successive depths. + factory HierarchyQuery.prefix( + String rawQuery, { + SearchTarget target, + }) = PrefixQuery; + + /// Create a **regex/glob** query. + /// + /// Segments are separated by `/`. Each segment is compiled as a + /// case-sensitive regex anchored to the full name. The special + /// segment `**` matches zero or more hierarchy levels. + /// + /// Glob wildcards `*` and `?` are auto-converted to regex equivalents. + factory HierarchyQuery.regex( + String rawQuery, { + SearchTarget target, + }) = RegexQuery; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart new file mode 100644 index 000000000..a82375b4b --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart @@ -0,0 +1,216 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller.dart +// Pure Dart controller for hierarchy search list navigation. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +/// Pure Dart controller for hierarchy search list navigation. +/// +/// Manages search results and keyboard-style list selection without +/// any Flutter dependency. Widgets call controller methods, then +/// refresh their own UI (e.g. `setState`). +/// +/// Generic over the result type [R] — typically [SignalSearchResult] +/// or [OccurrenceSearchResult]. +/// +/// ```dart +/// // In a Flutter widget: +/// final controller = HierarchySearchController.forSignals(hierarchy); +/// +/// void _onSearchChanged() { +/// controller.updateQuery(_textController.text); +/// setState(() {}); +/// } +/// ``` +class HierarchySearchController { + /// The search function that produces results from a normalised query. + final List Function(String normalizedQuery) _searchFn; + + /// Normalises a raw user query (e.g. replaces `.` with `/`). + final String Function(String rawQuery) _normalizeFn; + + List _results = []; + int _selectedIndex = 0; + + /// Create a controller with custom search and normalise functions. + HierarchySearchController({ + required List Function(String normalizedQuery) searchFn, + required String Function(String rawQuery) normalizeFn, + }) : _searchFn = searchFn, + _normalizeFn = normalizeFn; + + /// Create a controller for **signal** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forSignals( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchSignals(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + /// Create a controller for **occurrence** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forOccurrences( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchOccurrences(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + // ─────────────── State accessors ─────────────── + + /// The current search results. + List get results => _results; + + /// Index of the currently highlighted result. + int get selectedIndex => _selectedIndex; + + /// Whether there are any results. + bool get hasResults => _results.isNotEmpty; + + /// A human-readable counter string, e.g. `"3/12"`, or empty when + /// there are no results. + String get counterText => + hasResults ? '${_selectedIndex + 1}/${_results.length}' : ''; + + /// The currently selected result, or `null` if the list is empty. + R? get currentSelection => _results.isEmpty ? null : _results[_selectedIndex]; + + // ─────────────── Mutations ─────────────── + + /// Update search results for [rawQuery]. + /// + /// Normalises the query, runs the search function, and resets the + /// selection to the first result. The caller should rebuild its UI + /// after calling this. + void updateQuery(String rawQuery) { + if (rawQuery.isEmpty) { + _results = []; + _selectedIndex = 0; + return; + } + final normalized = _normalizeFn(rawQuery); + _results = _searchFn(normalized); + _selectedIndex = 0; + } + + /// Move selection to the next result, wrapping around. + void selectNext() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex + 1) % _results.length; + } + + /// Move selection to the previous result, wrapping around. + void selectPrevious() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex - 1 + _results.length) % _results.length; + } + + /// Move selection to a specific [index]. + /// + /// Clamps to valid range. Useful for tap-to-select in a list view. + void selectAt(int index) { + if (_results.isEmpty) { + return; + } + _selectedIndex = index.clamp(0, _results.length - 1); + } + + /// Clear all results and reset the selection index. + void clear() { + _results = []; + _selectedIndex = 0; + } + + // ─────────────── Tab-completion ─────────────── + + /// Compute the tab-completion expansion for [currentQuery]. + /// + /// Finds the longest common prefix of all current result display paths + /// and returns it if it is strictly longer than [currentQuery]. + /// Returns `null` when there is nothing to expand. + /// + /// [displayPath] extracts the comparable path string from each result. + /// The default implementation handles [SignalSearchResult] and + /// [OccurrenceSearchResult] automatically; pass a custom extractor for + /// other result types. + String? tabComplete( + String currentQuery, { + String Function(R result)? displayPath, + }) { + if (_results.isEmpty) { + return null; + } + + final extractor = displayPath ?? _defaultDisplayPath; + final paths = _results.map(extractor).toList(); + final prefix = HierarchyService.longestCommonPrefix(paths); + if (prefix == null) { + return null; + } + + // Normalise the query the same way UpdateQuery does so lengths are + // comparable (e.g. dots → slashes). + final normalizedQuery = _normalizeFn(currentQuery); + if (prefix.length <= normalizedQuery.length) { + return null; + } + return prefix; + } + + /// Default display-path extractor for the well-known result types. + static String _defaultDisplayPath(T result) { + if (result is HierarchySearchResult) { + return result.displayPath; + } + return result.toString(); + } + + // ─────────────── Scroll helper ─────────────── + + /// Compute the scroll offset needed to reveal the selected item in a + /// fixed-height list. + /// + /// Returns `null` if the item is already visible. The caller should + /// call `scrollController.jumpTo(offset)` with the returned value. + /// + /// This is a pure calculation with no Flutter dependency. + static double? scrollOffsetToReveal({ + required int selectedIndex, + required double itemHeight, + required double viewportHeight, + required double currentOffset, + }) { + final target = selectedIndex * itemHeight; + if (target < currentOffset) { + return target; + } + if (target + itemHeight > currentOffset + viewportHeight) { + return target + itemHeight - viewportHeight; + } + return null; + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart new file mode 100644 index 000000000..00bb0167c --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_result.dart +// Base class for hierarchy search results. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; + +/// Base class for hierarchy search results. +/// +/// Holds the common fields shared by signal and occurrence search +/// results: a canonical ID string, pre-split path segments, and display +/// helpers that strip the top-level module name. +@immutable +abstract class HierarchySearchResult { + /// The full hierarchical path that was found. + /// Example: `"Top/counter/clk"` or `"Top/CPU/ALU"`. + final String id; + + /// The hierarchical path segments. + /// Example: `["Top", "counter", "clk"]`. + final List path; + + /// Creates a hierarchy search result. + const HierarchySearchResult({required this.id, required this.path}); + + /// The leaf name (last path segment). + String get name => path.isNotEmpty ? path.last : id; + + // ───────────────────── Display helpers ───────────────────── + + /// Display path with the top-level module name stripped. + /// + /// For `Top/counter/clk` this returns `counter/clk`. + /// For a single-segment path returns the original [id]. + String get displayPath => displaySegments.join(hierarchyPathSeparator); + + /// Path segments with the top-level module name stripped. + /// + /// For `["Top", "counter", "clk"]` returns `["counter", "clk"]`. + List get displaySegments => path.length > 1 ? path.sublist(1) : path; + + /// Normalize a user query for hierarchy search. + /// + /// Converts common separators (`.`) to the canonical `/` separator. + static String normalizeQuery(String query) => + query.replaceAll('.', hierarchyPathSeparator); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HierarchySearchResult && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_service.dart b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart new file mode 100644 index 000000000..791293429 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart @@ -0,0 +1,875 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_service.dart +// Abstract interface for source-agnostic hardware hierarchy navigation. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// A source-agnostic interface for navigating hardware hierarchy. +/// +/// All search and navigation is driven by walking the [HierarchyOccurrence] +/// tree. Occurrences hold their [HierarchyOccurrence.name], +/// [HierarchyOccurrence.children], and [HierarchyOccurrence.signals]. Full +/// paths are constructed on the fly by joining names with +/// [hierarchyPathSeparator] +/// — no pre-baked path strings are needed for search. +/// +/// Key methods: +/// - [searchSignals] — incremental signal search +/// - [searchOccurrences] — find occurrences by name +/// - [matchOccurrences] — find occurrences, returning [HierarchyOccurrence] +/// objects +/// - [autocompletePaths] — incremental path completion +abstract mixin class HierarchyService { + /// The root occurrence for the hierarchy. + HierarchyOccurrence get root; + + // ───────────── Address-based occurrence/signal lookup ──────────────── + + /// Find an occurrence by its [OccurrenceAddress]. O(depth). + HierarchyOccurrence? occurrenceByAddress(OccurrenceAddress address) => + address.path.fold( + root, + (node, idx) => node != null && idx >= 0 && idx < node.children.length + ? node.children[idx] + : null); + + /// Find a signal by its [OccurrenceAddress]. + /// + /// The parent portion of [address] navigates to the owning occurrence; + /// the last index selects the signal within that occurrence. O(depth). + SignalOccurrence? signalByAddress(OccurrenceAddress address) { + if (address.path.isEmpty) { + return null; + } + final node = occurrenceByAddress( + OccurrenceAddress(address.path.sublist(0, address.path.length - 1))); + final sigIdx = address.path.last; + return (node != null && sigIdx >= 0 && sigIdx < node.signals.length) + ? node.signals[sigIdx] + : null; + } + + // ───────────── Address ↔ pathname conversion ────────────────── + + /// Convert a pathname (e.g. `"Top/sub/clk"` or `"Top.sub.clk"`) to a + /// [OccurrenceAddress] by walking the tree. + /// + /// Delegates to [OccurrenceAddress.tryFromPathname]. + OccurrenceAddress? pathnameToAddress(String pathname) => + OccurrenceAddress.tryFromPathname(pathname, root); + + /// Resolve a `/`-separated pathname to a [HierarchyOccurrence]. + /// + /// Convenience that composes [pathnameToAddress] and [occurrenceByAddress]. + /// Returns `null` when [pathname] does not match any occurrence in the + /// tree. + HierarchyOccurrence? occurrenceByPathname(String pathname) { + final addr = pathnameToAddress(pathname); + return addr == null ? null : occurrenceByAddress(addr); + } + + /// Convert a [OccurrenceAddress] back to a `/`-separated pathname by + /// walking the tree using child indices. + /// + /// Returns `null` if the address doesn't resolve in the current tree + /// (e.g. out-of-bounds indices). O(depth). + /// + /// For signal addresses, the last index is resolved as a signal within + /// the parent occurrence. For pure occurrence addresses, every index + /// is a child. + /// + /// Set [asSignal] to `true` when you know the address points to a signal + /// (the last index is a signal offset rather than a child offset). + /// When `false` (default), all indices are treated as child offsets. + String? addressToPathname(OccurrenceAddress address, + {bool asSignal = false}) { + if (address.path.isEmpty) { + return root.name; + } + + final indices = address.path; + final moduleEndIdx = asSignal ? indices.length - 1 : indices.length; + + final walked = indices + .sublist(0, moduleEndIdx) + .fold<({List parts, HierarchyOccurrence node})?>(( + parts: [root.name], + node: root, + ), (cur, idx) { + if (cur == null || idx < 0 || idx >= cur.node.children.length) { + return null; + } + final child = cur.node.children[idx]; + return (parts: [...cur.parts, child.name], node: child); + }); + if (walked == null) { + return null; + } + + if (asSignal && indices.isNotEmpty) { + final sigIdx = indices.last; + return (sigIdx >= 0 && sigIdx < walked.node.signals.length) + ? [...walked.parts, walked.node.signals[sigIdx].name] + .join(hierarchyPathSeparator) + : null; + } + return walked.parts.join(hierarchyPathSeparator); + } + + /// Resolve a signal identifier from a VCD or FST waveform file to an + /// [OccurrenceAddress]. + /// + /// VCD/FST waveform readers often expose hierarchy names as dot-separated + /// strings such as `"dut.adder.clk"`. This delegates to + /// [pathnameToAddress], which accepts both dot-separated waveform IDs and + /// slash-separated hierarchy paths. + OccurrenceAddress? waveformIdToAddress(String waveformId) => + pathnameToAddress(waveformId); + + // ───────────────────── Search / autocomplete ───────────────────── + + /// Find hierarchical signal paths matching [query]. + /// + /// Walks the tree, matching name segments incrementally. When the last + /// query segment partially matches a signal name at or below the current + /// node the full path is returned (e.g. `Top/block/signal`). + /// + /// Returns up to [limit] results. + List searchSignalPaths(String query, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchSignalsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Whether [query] contains glob or regex metacharacters that should + /// trigger the regex search engine instead of the plain substring search. + static bool hasRegexChars(String query) => + query.contains('*') || + query.contains('?') || + query.contains('[') || + query.contains('(') || + query.contains('|') || + query.contains('+'); + + /// Check if an occurrence or any of its descendants match [searchTerm]. + /// + /// The search term is split on `/` or `.` into hierarchical segments. + /// Each segment is matched via substring containment + /// against occurrence names at successive depths. + /// + /// Returns `true` if [searchTerm] is null/empty, or if the occurrence + /// (or a descendant) matches all segments in order. + /// + /// This is useful for tree-view filtering: show an occurrence only when + /// it or one of its descendants matches the user's query. + static bool isOccurrenceMatching( + HierarchyOccurrence node, String? searchTerm) { + if (searchTerm == null || searchTerm.isEmpty) { + return true; + } + + final normalizedQuery = searchTerm.replaceAll('.', hierarchyPathSeparator); + final queryParts = normalizedQuery + .split(hierarchyPathSeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _isOccurrenceMatchingRecursive(node, queryParts, 0); + } + + static bool _isOccurrenceMatchingRecursive( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx]; + final nodeName = node.name; + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + return node.children.any((child) => + _isOccurrenceMatchingRecursive(child, queryParts, nextQueryIdx)); + } + + /// Search for signals and return enriched [SignalSearchResult] objects. + /// + /// Automatically dispatches to [searchSignalsRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchSignalPaths] for prefix-based matching. + List searchSignals(String query, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '*/$query'; + return searchSignalsRegex(pattern, limit: effectiveLimit); + } + return _toSignalResults(searchSignalPaths(query, limit: effectiveLimit)); + } + + /// Find hierarchical occurrence paths matching [query]. + /// + /// Similar to [searchSignalPaths] but for occurrences instead of + /// signals. Walks the tree, matching name segments incrementally. When + /// the query segments match occurrence names at or below the current + /// level the full path is returned (e.g. `Top/CPU/ALU`). + /// + /// Returns up to [limit] results. + List searchOccurrencePaths(String query, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchOccurrencePathsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Find hierarchy occurrences whose path matches [query]. + /// + /// Like [searchOccurrencePaths] but returns the [HierarchyOccurrence] objects + /// themselves instead of path strings. + List matchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _matchOccurrencesRecursive(root, parts, 0, results, effectiveLimit); + return results; + } + + /// Autocomplete suggestions for a partial hierarchical path. + /// + /// The partial path is split into segments. Completed segments navigate + /// down the tree; the final (possibly empty) segment is used as a prefix + /// filter on children at that level. Returns up to [limit] full paths + /// (with `/` appended for nodes that have children). + List autocompletePaths(String partialPath, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + final normalized = partialPath.replaceAll('.', hierarchyPathSeparator); + final endsWithSep = normalized.endsWith(hierarchyPathSeparator); + final parts = _splitPath(partialPath); + + // Navigate to the deepest complete segment. + var current = root; + final completedParts = [root.name]; + + final navParts = endsWithSep || parts.isEmpty + ? parts + : parts.sublist(0, parts.length - 1); + for (final seg in navParts) { + // If the segment matches the current node name, stay at this level + // (handles the root name appearing as the first path segment). + if (current.name == seg) { + continue; + } + final child = current.children.where((c) => c.name == seg).firstOrNull; + if (child == null) { + return const []; + } + current = child; + completedParts.add(child.name); + } + + // The trailing prefix to filter on (empty if path ends with separator). + final prefix = (endsWithSep || parts.isEmpty) ? '' : parts.last; + + final suggestions = []; + + // When the prefix matches the current (root-level) node itself and we + // haven't navigated past it, suggest the root path so that typing a + // partial root name produces a completion. + if (prefix.isNotEmpty && + completedParts.length == 1 && + current == root && + current.name.startsWith(prefix)) { + final rootPath = current.name; + suggestions.add(current.children.isNotEmpty + ? '$rootPath$hierarchyPathSeparator' + : rootPath); + } + + for (final child in current.children) { + if (prefix.isEmpty || child.name.startsWith(prefix)) { + final pathParts = [...completedParts, child.name]; + final path = pathParts.join(hierarchyPathSeparator); + suggestions.add( + child.children.isNotEmpty ? '$path$hierarchyPathSeparator' : path); + if (suggestions.length >= effectiveLimit) { + break; + } + } + } + return suggestions; + } + + /// Search for occurrences and return enriched + /// [OccurrenceSearchResult] objects. + /// + /// Automatically dispatches to [searchOccurrencesRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchOccurrencePaths] for prefix-based matching. + List searchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '**/$query'; + return searchOccurrencesRegex(pattern, limit: effectiveLimit); + } + return _toOccurrenceResults( + searchOccurrencePaths(query, limit: effectiveLimit)); + } + + // ───────────────── Regex search ───────────────── + + /// Search for signals whose hierarchical path matches a regex [pattern]. + /// + /// The pattern is split on `/` or `.` into segments. Each segment is + /// compiled as a [RegExp] and matched against the + /// corresponding depth in the hierarchy tree. Special segments: + /// + /// - `**` — matches zero or more hierarchy levels (glob-star). Use this + /// to search across hierarchy boundaries, e.g. `Top/**/clk` finds + /// `Top/CPU/ALU/clk`, `Top/Memory/clk`, etc. + /// - Any other string is compiled as a regex anchored to the full name + /// (`^…$`). Plain names therefore match exactly and regex meta- + /// characters like `.*`, `[0-9]+`, etc. work as expected. + /// + /// Returns up to [limit] full hierarchical signal paths. + /// + /// Examples: + /// ```text + /// 'Top/CPU/clk' — exact match at each level + /// 'Top/CPU/.*' — all signals in Top/CPU + /// 'Top/.*/clk' — clk signal one level below Top + /// 'Top/**/clk' — clk signal at any depth below Top + /// 'Top/**/c.*' — signals starting with 'c' at any depth + /// '**/(clk|reset)' — clk or reset anywhere in hierarchy + /// 'Top/CPU/d[0-9]+' — signals like d0, d1, d12 in Top/CPU + /// ``` + List searchSignalPathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _searchSignalsRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for signals by regex pattern and return enriched results. + List searchSignalsRegex(String pattern, {int? limit}) => + _toSignalResults(searchSignalPathsRegex(pattern, limit: limit)); + + /// Search for occurrence paths matching a regex [pattern]. + /// + /// Same segment syntax as [searchSignalPathsRegex] but matches + /// occurrences instead of signals. + /// + /// Returns up to [limit] full hierarchical occurrence paths. + List searchOccurrencePathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? defaultHierarchySearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _matchOccurrencesRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for occurrences by regex pattern and return enriched results. + List searchOccurrencesRegex(String pattern, + {int? limit}) => + _toOccurrenceResults(searchOccurrencePathsRegex(pattern, limit: limit)); + + // ─────────────────── Utility helpers ─────────────────── + + /// Returns the longest common prefix shared by all [paths]. + /// + /// Comparison is case-sensitive. Returns `null` when [paths] is empty + /// or no common prefix exists. + static String? longestCommonPrefix(List paths) { + if (paths.isEmpty) { + return null; + } + final prefix = paths.skip(1).fold(paths.first, (pre, s) { + if (pre == null || pre.isEmpty) { + return null; + } + final end = pre.length < s.length ? pre.length : s.length; + final j = + Iterable.generate(end).takeWhile((i) => pre[i] == s[i]).length; + return j > 0 ? pre.substring(0, j) : null; + }); + return prefix; + } + + // ─────────────────── Private helpers ─────────────────── + + /// Split a query or path on `/` or `.` into non-empty segments. + static List _splitPath(String input) => input + .replaceAll('.', hierarchyPathSeparator) + .split(hierarchyPathSeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + /// Split a path on `/` or `.` into non-empty segments, preserving case. + /// + /// Use this when the result is for display or building [SignalSearchResult] + /// path parts — not for matching. + static List _splitPathPreserveCase(String input) => input + .replaceAll('.', hierarchyPathSeparator) + .split(hierarchyPathSeparator) + .where((s) => s.isNotEmpty) + .toList(); + + /// Enrich signal paths into [SignalSearchResult] objects. + List _toSignalResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return SignalSearchResult( + signalId: fullPath, + path: _splitPathPreserveCase(fullPath), + signal: addr != null ? signalByAddress(addr) : null, + ); + }).toList(); + + /// Enrich occurrence paths into [OccurrenceSearchResult] objects. + List _toOccurrenceResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return OccurrenceSearchResult( + occurrenceId: fullPath, + path: _splitPathPreserveCase(fullPath), + occurrence: (addr != null ? occurrenceByAddress(addr) : null) ?? root, + ); + }).toList(); + + /// Recursively search for signals matching query parts. + /// + /// Walks the tree maintaining the path of names. When the accumulated match + /// depth reaches the query length, checks signals at that node. Partial + /// last-segment matching also checks signals at partially-matched nodes. + /// + /// Uses [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] + /// directly. + void _searchSignalsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.startsWith(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // Determine how many query parts remain after any node-name match. + final remaining = queryParts.length - nextIdx; + + // If 0 or 1 query parts remain, search signals at this node. + if (remaining <= 1) { + // When the current node consumed the last segment (remaining==0, + // matched==true), reuse that segment as the signal filter so that + // e.g. "a" doesn't return every signal under a module named "alu". + // When remaining==0 because we're recursing into a subtree where + // a parent already consumed all segments, use empty (return all). + final signalQuery = remaining == 1 + ? queryParts[nextIdx] + : (matched && qIdx < queryParts.length ? queryParts[qIdx] : ''); + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (signalQuery.isEmpty || signal.name.startsWith(signalQuery)) { + final fullPath = + [...pathSoFar, signal.name].join(hierarchyPathSeparator); + results.add(fullPath); + } + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts. + /// + /// Similar to [_searchSignalsRecursive] but matches occurrences instead + /// of signals. Walks the tree maintaining the path of names. When the + /// query segments match occurrence names, adds them to results. + void _searchOccurrencePathsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.contains(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // If all query parts are matched, this node is a result + if (nextIdx >= queryParts.length) { + final fullPath = pathSoFar.join(hierarchyPathSeparator); + results.add(fullPath); + if (results.length >= limit) { + return; + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchOccurrencePathsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts, returning + /// the occurrences. + void _matchOccurrencesRecursive( + HierarchyOccurrence node, + List queryParts, + int qIdx, + List results, + int limit) { + if (results.length >= limit) { + return; + } + + final matched = + qIdx < queryParts.length && node.name.contains(queryParts[qIdx]); + final nextIdx = matched ? qIdx + 1 : qIdx; + + if (nextIdx >= queryParts.length) { + results.add(node); + if (results.length >= limit) { + return; + } + } + + for (final child in node.children) { + _matchOccurrencesRecursive(child, queryParts, nextIdx, results, limit); + if (results.length >= limit) { + return; + } + } + } + + // ─────────────── Regex search helpers ─────────────── + + /// A compiled regex segment. `isGlobStar` indicates a `**` segment that + /// matches zero or more hierarchy levels. + static const _globStarSentinel = '**'; + + /// Split `pattern` into segments on `/` only. + /// + /// Unlike [_splitPath] (which also splits on `.`), regex patterns use only + /// `/` as the hierarchy separator because `.` has meaning inside regular + /// expressions (e.g. `.*`, `a.b`). + List _splitRegexPattern(String input) => input + .split(hierarchyPathSeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + /// Convert glob-style `*` and `?` wildcards to regex equivalents. + /// + /// A standalone `*` (not preceded/followed by another regex metachar) + /// becomes `.*` (match anything). `?` becomes `.` (match one char). + /// This lets users write natural patterns like `*m`, `clk*`, `*data*` + /// without needing to know regex syntax. + String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + // If already preceded by `.` (i.e. user wrote `.*`), skip conversion. + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + // If already preceded by a valid quantifier target, keep literal `?`. + // Otherwise treat as single-char wildcard `.`. + if (i > 0 && !'.?*+'.contains(segment[i - 1])) { + buf.write('?'); + } else { + buf.write('.'); + } + } else { + buf.write(c); + } + } + return buf.toString(); + } + + /// Compile string segments into [_RegexSegment] list. + /// + /// Each segment is first run through [_globToRegex] so that glob-style + /// wildcards (`*`, `?`) work alongside full regex syntax. + List<_RegexSegment> _compileSegments(List segments) => + segments.map((s) { + if (s == _globStarSentinel) { + return _RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + // Anchor the regex to match the full name. + return _RegexSegment(RegExp('^$pattern\$')); + }).toList(); + + /// Recursive signal search driven by compiled regex segments. + /// + /// [segIdx] is the index into [segments] that we are currently trying to + /// match at this tree depth. + void _searchSignalsRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Determine how many segments remain after consuming the current node. + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // Try to match signals at this node. + // Find all indices reachable from nextIdx by skipping glob-stars + // where a signal-level regex (or end-of-pattern) can be applied. + for (final sigIdx in _signalReachableIndices(segments, nextIdx)) { + if (results.length >= limit) { + return; + } + if (sigIdx >= segments.length) { + // All segments consumed: collect all signals at this node. + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + final fullPath = + [...pathSoFar, signal.name].join(hierarchyPathSeparator); + results.add(fullPath); + } + } else { + // sigIdx points to a non-** regex that should match signal names. + final sigSeg = segments[sigIdx]; + // Only use as signal-level match if this is the last non-** segment + // (possibly followed by more **'s that can match zero levels). + if (_allGlobStarAfter(segments, sigIdx + 1)) { + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (sigSeg.regex!.hasMatch(signal.name)) { + results.add( + [...pathSoFar, signal.name].join(hierarchyPathSeparator)); + } + } + } + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Recursive occurrence search driven by compiled regex segments. + void _matchOccurrencesRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // All segments consumed (or only trailing **'s remain) → match. + if (_allGlobStarAfter(segments, nextIdx)) { + results.add(pathSoFar.join(hierarchyPathSeparator)); + if (results.length >= limit) { + return; + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _matchOccurrencesRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Try to match [nodeName] against the segment at [segIdx]. + /// + /// Returns a set of possible next-segment indices (branching is needed + /// because `**` can consume zero or more levels). + Set _matchNode( + String nodeName, List<_RegexSegment> segments, int segIdx) { + final results = {}; + if (segIdx >= segments.length) { + // No more segments to match — nothing to advance to. + return results; + } + + final seg = segments[segIdx]; + + if (seg.isGlobStar) { + // ** matches zero levels (skip the **) … + results + ..addAll(_matchNode(nodeName, segments, segIdx + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(segIdx); + } else if (seg.regex!.hasMatch(nodeName)) { + results.add(segIdx + 1); + } + // If the segment doesn't match at all, return empty → prune this branch. + return results; + } + + /// Returns indices in [segments] reachable from [fromIdx] by skipping + /// consecutive `**` glob-star segments. Always includes [fromIdx] itself + /// if it is in range (or == segments.length, meaning "past the end"). + Set _signalReachableIndices(List<_RegexSegment> segments, int fromIdx) { + final result = {}; + var i = fromIdx; + // Walk forward: each time we see a **, we can skip it (zero levels). + while (i < segments.length) { + if (segments[i].isGlobStar) { + // ** can match zero levels → skip and also record i (stay at **). + result.add(i + 1); // skip the ** + i++; + } else { + result.add(i); + break; // stop at first non-** segment + } + } + // If we walked past the end, record that too. + if (i >= segments.length) { + result.add(segments.length); + } + return result; + } + + /// Returns true if all segments from [fromIdx] onward are glob-stars + /// (or if [fromIdx] >= length, i.e. no more segments). + bool _allGlobStarAfter(List<_RegexSegment> segments, int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); +} + +/// Internal representation of a compiled regex segment. +class _RegexSegment { + final RegExp? regex; + final bool isGlobStar; + + _RegexSegment(this.regex) : isGlobStar = false; + _RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart new file mode 100644 index 000000000..6ec675293 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_hierarchy_adapter.dart +// Hierarchy adapter for netlist JSON format (derived from Yosys JSON) +// using rohd_hierarchy. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd_hierarchy/src/base_hierarchy_adapter.dart'; +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// Adapter that exposes a netlist as a source-agnostic hierarchy. +/// +/// Extends [BaseHierarchyAdapter] from rohd_hierarchy package, using the shared +/// implementation for search, autocomplete, and lookup methods. +/// Only the netlist format-specific +/// JSON parsing logic is implemented here. +/// +/// Features: +/// - Parses ports, netnames, and cells from netlist JSON +/// - Filters auto-generated netnames (`hide_name`, `$`-prefixed, port dupes) +/// - Extracts `port_directions` on primitive cells for signal visibility +/// - Supports optional root-name override for VCD name alignment +class NetlistHierarchyAdapter extends BaseHierarchyAdapter { + NetlistHierarchyAdapter._(); + + /// Convenience factory to parse a netlist JSON string directly. + /// + /// [rootNameOverride] replaces the top-module name derived from the JSON. + /// Use this when VCD scopes use instance names that differ from the + /// definition names in the netlist output (often capitalized). + factory NetlistHierarchyAdapter.fromJson( + String netlistJson, { + String? rootNameOverride, + }) { + final obj = jsonDecode(netlistJson); + if (obj is! Map) { + throw const FormatException('Invalid netlist JSON root'); + } + return NetlistHierarchyAdapter.fromMap( + obj, + rootNameOverride: rootNameOverride, + ); + } + + /// Factory to parse a pre-decoded netlist JSON map. + /// + /// [netlistJson] must contain a top-level `modules` key. + /// [rootNameOverride] optionally replaces the detected top-module name. + factory NetlistHierarchyAdapter.fromMap( + Map netlistJson, { + String? rootNameOverride, + }) { + final adapter = NetlistHierarchyAdapter._() + .._buildFromNetlist(netlistJson, rootNameOverride: rootNameOverride); + return adapter; + } + + void _buildFromNetlist( + Map netlistJson, { + String? rootNameOverride, + }) { + final modules = netlistJson['modules'] as Map?; + if (modules == null || modules.isEmpty) { + throw const FormatException('Netlist JSON contained no modules'); + } + + // Find top module or default to first + final topName = modules.entries + .where( + (e) => + ((e.value as Map)['attributes'] + as Map?)?['top'] == + 1, + ) + .map((e) => e.key) + .firstOrNull ?? + modules.keys.first; + + final resolvedRootName = rootNameOverride ?? topName; + + final rootNode = _parseModule( + name: resolvedRootName, + definition: topName, + moduleData: modules[topName] as Map, + allModules: modules, + ); + root = rootNode; + rootNode.buildAddresses(); + } + + /// Parse a module definition and return the created + /// [HierarchyOccurrence]. + HierarchyOccurrence _parseModule({ + required String name, + required String definition, + required Map moduleData, + required Map allModules, + }) { + // Ports (signals with direction) + final portsData = moduleData['ports'] as Map?; + final signalsList = [ + if (portsData != null) + ...portsData.entries.indexed.map((entry) { + final (idx, kv) = entry; + final p = kv.value as Map; + final dir = p['direction']?.toString() ?? 'inout'; + final bits = (p['bits'] as List?)?.length ?? 0; + final logicType = p['logic_type'] as Map?; + return SignalOccurrence( + name: kv.key, + direction: dir, + width: bits > 0 ? bits : 1, + portIndex: idx, + logicType: logicType, + ); + }), + ]; + + // Netnames (internal signals without direction). + // Netlist `netnames` contains ALL named signals including port-connected + // ones. We skip names already covered by `ports` above, as well as + // auto-generated names (hide_name=1 or $-prefixed). + final netsData = moduleData['netnames'] as Map?; + if (netsData != null) { + final portNames = portsData?.keys.toSet() ?? {}; + signalsList.addAll( + netsData.entries + .where( + (entry) => + !portNames.contains(entry.key) && + !entry.key.startsWith(r'$') && + () { + final h = (entry.value as Map)['hide_name']; + return h != 1 && h != '1'; + }(), + ) + .map((entry) { + final netData = entry.value as Map; + final bits = (netData['bits'] as List?)?.length ?? 0; + final attrs = netData['attributes'] as Map?; + final isComputed = + attrs?['computed'] == 1 || attrs?['computed'] == true; + final logicType = netData['logic_type'] as Map?; + return SignalOccurrence( + name: entry.key, + width: bits > 0 ? bits : 1, + isComputed: isComputed, + logicType: logicType, + ); + }), + ); + } + + // Cells -> submodules or instances + final childNodes = []; + final cells = moduleData['cells'] as Map?; + if (cells != null) { + for (final entry in cells.entries) { + final cellName = entry.key; + final cellData = entry.value as Map; + final cellType = cellData['type']?.toString() ?? ''; + + if (allModules.containsKey(cellType) && + !HierarchyOccurrence.isPrimitiveType(cellType)) { + final childNode = _parseModule( + name: cellName, + definition: cellType, + moduleData: allModules[cellType] as Map, + allModules: allModules, + ); + childNodes.add(childNode); + } else { + // Primitive cell — create leaf occurrence. + // Extract port signals from `port_directions` when available so + // that primitive I/O appears in signal search results. + final isCellComputed = cellType.startsWith(r'$'); + final portDirections = + cellData['port_directions'] as Map?; + final connections = cellData['connections'] as Map?; + final portWidths = cellData['port_widths'] as Map?; + final cellSignals = [ + if (portDirections != null) + ...portDirections.entries.indexed.map((pEntry) { + final (pIdx, kv) = pEntry; + final pName = kv.key; + final pDir = kv.value.toString(); + final bits = (connections?[pName] as List?)?.length ?? + (portWidths?[pName] as int?) ?? + 1; + return SignalOccurrence( + name: pName, + direction: pDir, + width: bits, + isComputed: isCellComputed, + portIndex: pIdx, + ); + }), + ]; + + final instNode = HierarchyOccurrence( + name: cellName, + definition: cellType, + isPrimitive: true, + signals: cellSignals, + ); + childNodes.add(instNode); + } + } + } + + // Create the occurrence with children and signals embedded + return HierarchyOccurrence( + name: name, + definition: definition, + signals: signalsList, + children: childNodes, + ); + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_address.dart b/packages/rohd_hierarchy/lib/src/occurrence_address.dart new file mode 100644 index 000000000..06f059c88 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_address.dart @@ -0,0 +1,144 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address.dart +// Efficient hierarchical address using indices instead of strings. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; + +/// Efficient hierarchical address using indices instead of strings. +/// +/// Format: [index0, index1, ...] or [] for root. +/// Example: [0, 2, 4] means root's 0th child, then 2nd child of that, then +/// the 4th child (occurrence) or 4th signal, depending on context. +/// +/// Advantages: +/// - O(1) address creation (just append index) +/// - O(depth) tree navigation (direct array indexing) +/// - Deterministic serialization (no parsing needed) +/// - Natural alignment with waveform dictionary (integer indices) +/// - Supports hierarchical queries (ancestor matching, batching by prefix) +/// +/// This replaces string-based path lookups with typed, semantic addressing. +@immutable +class OccurrenceAddress { + /// Path through tree as indices stored as immutable list. + /// Empty list represents the root occurrence. + /// Non-empty list: indices navigate through the hierarchy. The last index + /// refers to either a child occurrence or a signal, depending on context. + final List path; + + /// Create a hierarchy address from a path list. + const OccurrenceAddress(this.path); + + /// Root address (empty path). + static const OccurrenceAddress root = OccurrenceAddress([]); + + /// Create a child address by appending an occurrence index. + /// Use this when navigating to a child occurrence. + OccurrenceAddress child(int childIndex) => + OccurrenceAddress([...path, childIndex]); + + /// Create a signal address by appending signal index. + /// Use this when addressing a signal within current occurrence. + OccurrenceAddress signal(int signalIndex) => + OccurrenceAddress([...path, signalIndex]); + + /// Serialize to a dot-separated string suitable for use as a JSON key. + /// + /// Examples: `""` (root), `"0"`, `"0.2.4"`. + /// Round-trips with [OccurrenceAddress.fromDotString]. + String toDotString() => path.join('.'); + + /// Deserialize from a dot-separated string produced by [toDotString]. + /// + /// An empty string returns [root]. + factory OccurrenceAddress.fromDotString(String s) { + if (s.isEmpty) { + return root; + } + return OccurrenceAddress(s.split('.').map(int.parse).toList()); + } + + @override + String toString() { + if (path.isEmpty) { + return '[ROOT]'; + } + return '[${path.join(".")}]'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OccurrenceAddress && + const ListEquality().equals(path, other.path); + + @override + int get hashCode => Object.hashAll(path); + + /// Resolve a pathname string (e.g. `"Top/counter/clk"` or + /// `"Top.counter.clk"`) to a [OccurrenceAddress] by walking [root]. + /// + /// Supports both `/` hierarchy paths and dot-separated signal identifiers + /// commonly produced by VCD/FST waveform files. If the first segment matches + /// [root]'s name, it is skipped — the root occurrence is always at the empty + /// address. + /// + /// The last segment is first tried as a **signal** name within the + /// current occurrence; if that fails it is tried as a **child** + /// occurrence name. + /// This mirrors the pathname convention where a signal path has one more + /// segment than its parent module path. + /// + /// Returns `null` if any segment cannot be resolved. + /// + /// ```dart + /// final addr = OccurrenceAddress.tryFromPathname('Top/cpu/clk', root); + /// if (addr != null) { + /// final signal = service.signalByAddress(addr); + /// } + /// ``` + static OccurrenceAddress? tryFromPathname( + String pathname, + HierarchyOccurrence root, + ) { + final rootAddr = root.address ?? OccurrenceAddress.root; + final parts = pathname + .replaceAll('.', hierarchyPathSeparator) + .split(hierarchyPathSeparator) + .where((s) => s.isNotEmpty) + .toList(); + + // Skip leading segment that matches the root name. + final segments = + parts.isNotEmpty && parts.first == root.name ? parts.skip(1) : parts; + + ({HierarchyOccurrence node, OccurrenceAddress addr})? step( + ({HierarchyOccurrence node, OccurrenceAddress addr})? cur, + String segment, + ) { + if (cur == null) { + return null; + } + final si = cur.node.signalIndexByName(segment); + if (identical(segment, segments.last) && si >= 0) { + return (node: cur.node, addr: cur.addr.signal(si)); + } + final ci = cur.node.childIndexByName(segment); + return ci >= 0 + ? (node: cur.node.children[ci], addr: cur.addr.child(ci)) + : null; + } + + return segments.fold<({HierarchyOccurrence node, OccurrenceAddress addr})?>( + (node: root, addr: rootAddr), step)?.addr; + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart new file mode 100644 index 000000000..fafe8623c --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_search_result.dart +// Result of a module/node search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; + +/// Result of an occurrence search with enriched metadata. +/// +/// Contains the occurrence's full path, parsed path segments, and the full +/// [HierarchyOccurrence] object. This mirrors `SignalSearchResult` for +/// occurrences and provides a consistent search results interface. +@immutable +class OccurrenceSearchResult extends HierarchySearchResult { + /// Alias for [id] — the occurrence's full hierarchical path. + String get occurrenceId => id; + + /// The underlying [HierarchyOccurrence] from the hierarchy service. + /// Contains the occurrence's name, type, children, and signals. + final HierarchyOccurrence occurrence; + + /// Creates an occurrence search result. + const OccurrenceSearchResult({ + required String occurrenceId, + required super.path, + required this.occurrence, + }) : super(id: occurrenceId); + + /// Whether this occurrence has sub-hierarchy (i.e. is not a primitive + /// leaf). + bool get isModule => !occurrence.isPrimitive; + + /// Number of direct child occurrences. + int get childCount => occurrence.children.length; + + @override + String toString() => 'OccurrenceSearchResult($id)'; +} diff --git a/packages/rohd_hierarchy/lib/src/prefix_query.dart b/packages/rohd_hierarchy/lib/src/prefix_query.dart new file mode 100644 index 000000000..4f12b7ad7 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/prefix_query.dart @@ -0,0 +1,61 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// prefix_query.dart +// Prefix-substring query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Prefix-substring query: segments are matched via `startsWith` (signals) +/// or `contains` (occurrences) at successive hierarchy depths. +class PrefixQuery extends HierarchyQuery { + /// Non-empty segments parsed from the raw query. + late final List segments; + + /// Create a prefix query from [rawQuery]. + PrefixQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + segments = rawQuery + .replaceAll('.', hierarchyPathSeparator) + .split(hierarchyPathSeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return {stateIndex}; + } + final name = occurrenceName; + if (name.contains(segments[stateIndex])) { + return {stateIndex + 1}; + } + return const {}; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + if (stateIndex >= segments.length) { + return true; + } + // Only the last segment can match a signal name. + if (stateIndex != segments.length - 1) { + return false; + } + return signalName.startsWith(segments[stateIndex]); + } + + @override + bool isComplete(int stateIndex) => stateIndex >= segments.length; +} diff --git a/packages/rohd_hierarchy/lib/src/regex_query.dart b/packages/rohd_hierarchy/lib/src/regex_query.dart new file mode 100644 index 000000000..4b367b7e6 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/regex_query.dart @@ -0,0 +1,177 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_query.dart +// Regex/glob query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Regex/glob query: each segment is a compiled regex, with `**` support +/// for crossing hierarchy boundaries. +/// +/// ## Segment syntax +/// +/// The query string is split on `/` into segments. Each segment is +/// independently compiled as a case-sensitive [RegExp] anchored to the +/// full occurrence or signal name (`^…$`). This means: +/// +/// - **Plain names** match exactly: `Top/CPU/clk`. +/// - **Glob wildcards** are auto-converted before compilation: +/// - `*` → `.*` (match any characters) +/// - `?` → `.` (match one character) +/// - These compose naturally: `clk*` matches `clk`, `clk_gated`, +/// `clk_div2`, etc. +/// - **Full regex** is supported within each segment since the string +/// is passed to [RegExp]: +/// - `d[0-9]+` — signals named `d0`, `d1`, `d12`, … +/// - `(clk|reset)` — either `clk` or `reset` +/// - `data_[a-z]{2}` — `data_ab`, `data_xy`, … +/// - `.*mux.*` — any name containing `mux` +/// - `ch[0-3]` — `ch0`, `ch1`, `ch2`, `ch3` +/// - `r[0-9]{1,2}` — `r0` through `r99` +/// - **`**`** (double-star, as its own segment) matches zero or more +/// hierarchy levels, allowing searches to cross boundaries: +/// - `Top/**/clk` — `clk` at any depth below `Top` +/// - `**/d[0-9]+` — any signal like `d0` anywhere +/// - `Top/**/ch*/data_*` — `data_*` signals inside `ch*` modules +/// +/// ## Interaction between glob and regex +/// +/// Glob conversion happens *before* regex compilation, so `*` and `?` +/// are always expanded. If you need a literal `*` or `?` in the regex, +/// escape them: `\*`, `\?`. All other regex metacharacters (`.`, `+`, +/// `|`, `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`) work as-is inside +/// each segment. +/// +/// ## Examples +/// +/// ```text +/// Query Matches +/// ───────────────────────────────────────────────────────────── +/// Top/CPU/clk exact: Top → CPU → clk +/// Top/CPU/* all signals in Top/CPU +/// Top/*/clk clk one level below Top +/// Top/**/clk clk at any depth below Top +/// Top/**/c.* signals starting with 'c' anywhere +/// **/clk clk anywhere in hierarchy +/// **/(clk|reset) clk or reset anywhere +/// Top/CPU/d[0-9]+ d0, d1, d12, … in Top/CPU +/// Top/**/ch[0-3]/data_* data_* in ch0–ch3 at any depth +/// Top/mem_*/addr[0-9]* addr0, addr1, … in mem_* modules +/// **/.*mux.* any name containing 'mux' anywhere +/// ``` +class RegexQuery extends HierarchyQuery { + /// Compiled segments — either a regex or a glob-star sentinel. + late final List segments; + + /// Create a regex query from [rawQuery]. + /// + /// A standalone `*` is converted to `.*`, `?` to `.`. The segment + /// `**` matches zero or more hierarchy levels. + RegexQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + final parts = rawQuery + .split(hierarchyPathSeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + segments = parts.map((s) { + if (s == '**') { + return RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + return RegexSegment(RegExp('^$pattern\$')); + }).toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return const {}; + } + final seg = segments[stateIndex]; + final results = {}; + if (seg.isGlobStar) { + // ** matches zero levels (skip) … + results + ..addAll(matchOccurrence(occurrenceName, stateIndex + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(stateIndex); + } else if (seg.regex!.hasMatch(occurrenceName)) { + results.add(stateIndex + 1); + } + return results; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + // Walk past any trailing **'s to find the signal-matching segment. + var i = stateIndex; + while (i < segments.length && segments[i].isGlobStar) { + i++; + } + if (i >= segments.length) { + return true; // all consumed + } + // The segment at i must be the last real regex. + if (!_allGlobStarAfter(i + 1)) { + return false; + } + return segments[i].regex!.hasMatch(signalName); + } + + @override + bool isComplete(int stateIndex) => + stateIndex >= segments.length || + segments.skip(stateIndex).every((s) => s.isGlobStar); + + /// Check if all segments from [fromIdx] onward are glob-stars. + bool _allGlobStarAfter(int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); + + /// Convert glob wildcards to regex equivalents. + static String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + buf.write('.'); + } else { + buf.write(c); + } + } + return buf.toString(); + } +} + +/// A compiled regex segment for [RegexQuery]. +class RegexSegment { + /// The compiled regex, or null for glob-star segments. + final RegExp? regex; + + /// Whether this segment is a `**` glob-star. + final bool isGlobStar; + + /// Create a regex segment. + RegexSegment(this.regex) : isGlobStar = false; + + /// Create a glob-star segment (`**`). + RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_occurrence.dart b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart new file mode 100644 index 000000000..3fb57772f --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart @@ -0,0 +1,274 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_occurrence.dart +// A signal in the hardware occurrence hierarchy. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/hierarchy_constants.dart'; +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; + +/// Signals are the fundamental data carriers in hardware. A signal can be: +/// - An internal signal within an occurrence +/// - A port on an occurrence interface (has direction: input/output/inout) +/// +/// This is a structural model without waveform data. Path strings are +/// computed on demand from the parent occurrence reference — call [path] +/// with your desired separator. +class SignalOccurrence { + /// The name of the signal (bare name within its scope). + /// + /// Used for display, search, and local lookups within an occurrence. + /// Not guaranteed unique across the full hierarchy — use [path] for + /// unique keying. + final String name; + + /// The bit width of the signal. + final int width; + + /// Direction of the signal if it's a port. + /// Null for internal signals. + /// "input", "output", or "inout" for ports. + final String? direction; + + /// Current runtime value of the signal (if available). + /// Typically a hex or binary string representation. + final String? value; + + /// Whether this signal's value is computed/derivable (e.g. constant, + /// gate output, InlineSystemVerilog result) rather than directly tracked + /// by the waveform service. + final bool isComputed; + + /// Stable ordering index among ports in the parent occurrence. + /// + /// Set by the adapter that creates the signal. For ports (signals with + /// a [direction]), this records the deterministic position from the + /// original source (netlist JSON iteration order, ROHD module port + /// declaration order, etc.). Internal signals have `null`. + /// + /// [HierarchyOccurrence.buildAddresses] places ports before internal + /// signals when assigning [OccurrenceAddress] indices, so a port with + /// `portIndex == k` will receive signal address index `k`. + /// + /// Consumers that store connectivity by `(nodeId, portIndex)` tuples + /// (e.g. schematic hyperedges) rely on this value remaining stable + /// across incremental hierarchy expansion. + final int? portIndex; + + /// Type metadata from the netlist `logic_type` JSON field. + /// + /// For a **LogicStructure** (non-array), the format is: + /// ```json + /// {"typeName": "FloatingPoint", "fields": [ + /// {"name": "mantissa", "width": 4, "bits": [0,1,2,3]}, + /// {"name": "exponent", "width": 4, "bits": [4,5,6,7]}, + /// {"name": "sign", "width": 1, "bits": [8]} + /// ]} + /// ``` + /// + /// For a **LogicArray**, the format is: + /// ```json + /// {"width": 80, "arrayDims": [10], "elementWidth": 8} + /// ``` + /// + /// For a plain signal: `{"width": N}` or `null`. + /// + /// Nested structs have a recursive `"type"` key in their field entries. + Map? logicType; + + /// Hierarchical address for this signal. Assigned by + /// [HierarchyOccurrence.buildAddresses] to enable efficient navigation. + /// Format: [...occurrenceIndices, signalIndex] + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Sets the address. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set address(OccurrenceAddress? value) => _address = value; + + /// Parent occurrence containing this signal. Set by + /// [HierarchyOccurrence.buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Sets the parent. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set parent(HierarchyOccurrence? value) => _parent = value; + + /// Creates a [SignalOccurrence] with the given properties. + SignalOccurrence({ + required this.name, + required this.width, + this.direction, + this.value, + this.isComputed = false, + this.portIndex, + this.logicType, + }); + + /// Whether this signal is a LogicStructure (has named sub-fields). + bool get isStruct => logicType != null && logicType!.containsKey('fields'); + + /// Whether this signal is a LogicArray (has indexed elements). + bool get isArray => logicType != null && logicType!.containsKey('arrayDims'); + + /// The struct type name (e.g. "FloatingPoint"), or null if not a struct. + String? get typeName => logicType?['typeName'] as String?; + + /// The struct field descriptors, or empty list if not a struct. + /// + /// Each field is `{"name": ..., "width": ..., "bits": [...]}` with an + /// optional `"type"` key for nested structs/arrays. + List> get structFields => + (logicType?['fields'] as List?)?.cast>() ?? + const []; + + /// Array dimensions (e.g. `[10]` for 1D, `[10, 2]` for 2D), or null. + List? get arrayDims => + (logicType?['arrayDims'] as List?)?.cast(); + + /// Element width for arrays, or null if not an array. + int? get arrayElementWidth => logicType?['elementWidth'] as int?; + + /// Returns the expected sub-field signal names derived from [logicType]. + /// + /// For structs, the synthesizer creates separate netnames for each field + /// following the Namer/Sanitizer conventions: + /// `Sanitizer.sanitizeSV(structureName)` → `{parentName}_{fieldName}` + /// + /// For example, signal `fp` with fields `mantissa`, `exponent`, `sign` + /// produces sub-field signal names: `fp_mantissa`, `fp_exponent`, `fp_sign`. + /// + /// These become separate [SignalOccurrence] entries in the same parent + /// module. Use `HierarchyOccurrence.findSubFieldSignals` to look + /// them up. + /// + /// Returns a list of `(expectedName, fieldLabel, width, startBit, + /// subLogicType)` for direct children. `expectedName` follows the + /// `{parentSignalName}_{fieldName}` convention. + /// `subLogicType` is non-null when the child is itself a sub-array + /// (remaining dimensions) and can be further expanded. + /// Empty if this is not a struct/array with known sub-fields. + List< + ({ + String expectedName, + String fieldLabel, + int width, + int startBit, + Map? subLogicType, + })> get subFieldDescriptors { + if (logicType == null) { + return const []; + } + return subFieldDescriptorsForType(logicType!, name); + } + + /// Compute sub-field descriptors for an arbitrary [logicType] map. + /// + /// [parentName] is used to derive expected signal names. + /// This is static so it can be called recursively for nested arrays + /// without needing a full [SignalOccurrence]. + static List< + ({ + String expectedName, + String fieldLabel, + int width, + int startBit, + Map? subLogicType, + })> subFieldDescriptorsForType( + Map logicType, + String parentName, + ) { + final fields = logicType['fields'] as List?; + if (fields != null) { + return fields.map((f) { + final field = f as Map; + final fieldName = field['name'] as String? ?? '?'; + final width = field['width'] as int? ?? 1; + final bits = field['bits'] as List?; + final startBit = bits != null && bits.isNotEmpty + ? (bits.cast().reduce((a, b) => a < b ? a : b)) + : 0; + // Naming convention: Sanitizer.sanitizeSV("$parentName.$fieldName") + // which produces "$parentName_$fieldName" + final expectedName = '${parentName}_$fieldName'; + return ( + expectedName: expectedName, + fieldLabel: fieldName, + width: width, + startBit: startBit, + subLogicType: field['type'] as Map?, + ); + }).toList(); + } + + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null && arrayDims.isNotEmpty) { + final leafWidth = (logicType['elementWidth'] as int?) ?? 1; + final outerDim = arrayDims.first as int; + // For multi-dimensional arrays, each outer element spans all + // remaining dimensions times the leaf element width. + final remainingDims = + arrayDims.length > 1 ? arrayDims.sublist(1).cast() : []; + final elementWidth = remainingDims.isEmpty + ? leafWidth + : remainingDims.fold(leafWidth, (acc, d) => acc * d); + + // Build sub-logicType for remaining dimensions (if any). + final subLogicType = remainingDims.isEmpty + ? null + : { + 'width': elementWidth, + 'arrayDims': remainingDims, + 'elementWidth': leafWidth, + }; + + return List.generate(outerDim, (i) { + // Naming convention: Sanitizer.sanitizeSV("$parentName[$i]") + // which produces "$parentName_${i}_" + final expectedName = '${parentName}_${i}_'; + return ( + expectedName: expectedName, + fieldLabel: '[$i]', + width: elementWidth, + startBit: i * elementWidth, + subLogicType: subLogicType, + ); + }); + } + + return const []; + } + + /// Compute the full hierarchical path for this signal. + /// + /// Joins the parent occurrence's path with this signal's [name] using + /// [separator]. Falls back to just [name] if parent is not yet set + /// (e.g. in test fixtures before `buildAddresses`). + String path({String separator = hierarchyPathSeparator}) { + if (_parent == null) { + return name; + } + return '${_parent!.path(separator: separator)}$separator$name'; + } + + /// Returns true if this signal is a port (has a direction). + bool get isPort => direction != null; + + /// Returns true if this is an input port. + bool get isInput => direction == 'input'; + + /// Returns true if this is an output port. + bool get isOutput => direction == 'output'; + + /// Returns true if this is a bidirectional port. + bool get isInout => direction == 'inout'; + + @override + String toString() => '$name (width=$width${isPort ? ', $direction' : ''})'; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_search_result.dart b/packages/rohd_hierarchy/lib/src/signal_search_result.dart new file mode 100644 index 000000000..970855f74 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_search_result.dart @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result.dart +// Result of a signal search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// Result of a signal search with enriched metadata. +/// +/// Contains the signal's full path, parsed path segments, and the full +/// [SignalOccurrence] object if available. This is the hierarchy-only portion +/// of search results; UI layers can use the pre-computed display helpers +/// directly without re-parsing paths. +@immutable +class SignalSearchResult extends HierarchySearchResult { + /// Alias for [id] — the signal's full hierarchical path. + String get signalId => id; + + /// The underlying [SignalOccurrence] from the hierarchy service (if + /// available). Contains width, direction, and other signal metadata. + final SignalOccurrence? signal; + + /// Creates a signal search result. + const SignalSearchResult({ + required String signalId, + required super.path, + this.signal, + }) : super(id: signalId); + + /// Occurrence names that need to be expanded to reveal this signal. + /// + /// These are the intermediate path segments between the top occurrence + /// and the signal name — i.e. everything except the first (top + /// occurrence) and last (signal name) segments. + /// + /// For `Top/sub1/sub2/clk` this returns `["sub1", "sub2"]`. + List get intermediateOccurrenceNames => + path.length > 2 ? path.sublist(1, path.length - 1) : const []; + + @override + String toString() => 'SignalSearchResult($id, width=${signal?.width ?? "?"})'; +} diff --git a/packages/rohd_hierarchy/pubspec.yaml b/packages/rohd_hierarchy/pubspec.yaml new file mode 100644 index 000000000..68c9bc4c8 --- /dev/null +++ b/packages/rohd_hierarchy/pubspec.yaml @@ -0,0 +1,19 @@ +name: rohd_hierarchy +description: "Generic hierarchy data models for hardware module navigation - HierarchyNode, Port, and HierarchyService." +homepage: https://intel.github.io/rohd-website/ +repository: https://github.com/intel/rohd +version: 0.1.0 +issue_tracker: https://github.com/intel/rohd/issues + +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + collection: ^1.15.0 + meta: ^1.9.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.17.3 diff --git a/packages/rohd_hierarchy/test/adapter_search_parity_test.dart b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart new file mode 100644 index 000000000..cde3a301a --- /dev/null +++ b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// adapter_search_parity_test.dart +// Baseline tests verifying that search produces identical results +// regardless of which adapter populated the HierarchyService. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// This is the key contract: once a HierarchyService is built, callers +// cannot tell whether the data came from VCD (BaseHierarchyAdapter.fromTree), +// netlist JSON (NetlistHierarchyAdapter), or any other source. + +import 'dart:convert'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Concrete subclass that does NOT set root, so we can test the +/// StateError thrown by uninitialized access. +class _UnsetAdapter extends BaseHierarchyAdapter {} + +/// Resolve a pathname to a [SignalOccurrence] via +/// [OccurrenceAddress.tryFromPathname]. +SignalOccurrence? _resolve(HierarchyService svc, String path) { + final addr = OccurrenceAddress.tryFromPathname(path, svc.root); + if (addr == null) { + return null; + } + return svc.signalByAddress(addr); +} + +// ────────────────────────────────────────────────────────────────────── +// Build the SAME design via two different adapter paths +// ────────────────────────────────────────────────────────────────────── + +/// VCD-style: HierarchyNode tree with children/signals populated inline. +/// This is what `wellen` produces when loading a VCD/FST file. +BaseHierarchyAdapter _buildVcdAdapter() => BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'Abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'resetn', width: 1), + SignalOccurrence(name: 'arvalid_s', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'fromUpstream_request__st', width: 64), + ], + children: [ + HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'entry', width: 32), + ], + ), + ], + ), + ], + ), + ); + +/// Netlist JSON-style: flat-map adapter (like what DevTools/schematic viewer +/// builds from ROHD inspector JSON or netlist JSON). +/// Children and signals live in the adapter's flat maps, NOT inside +/// the HierarchyNode objects. +NetlistHierarchyAdapter _buildJsonAdapter() => + NetlistHierarchyAdapter.fromJson(jsonEncode({ + 'modules': { + 'Abcd': { + 'attributes': {'top': 1}, + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [1] + }, + 'resetn': { + 'direction': 'input', + 'bits': [2] + }, + 'arvalid_s': { + 'direction': 'input', + 'bits': [3] + }, + }, + 'netnames': {}, + 'cells': { + 'lab': { + 'type': 'Lab', + 'connections': {}, + }, + }, + }, + 'Lab': { + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [10] + }, + 'reset': { + 'direction': 'input', + 'bits': [11] + }, + 'fromUpstream_request__st': { + 'direction': 'input', + 'bits': List.generate(64, (i) => 100 + i) + }, + }, + 'netnames': {}, + 'cells': { + 'cam': { + 'type': 'Cam', + 'connections': {}, + }, + }, + }, + 'Cam': { + 'ports': { + 'hit': { + 'direction': 'input', + 'bits': [200] + }, + 'entry': { + 'direction': 'input', + 'bits': List.generate(32, (i) => 300 + i) + }, + }, + 'netnames': {}, + 'cells': {}, + }, + }, + })); + +void main() { + late HierarchyService vcdService; + late HierarchyService jsonService; + + setUp(() { + vcdService = _buildVcdAdapter(); + jsonService = _buildJsonAdapter(); + }); + + // ── The two services must be interchangeable for all search ops ── + // Case-insensitivity, dot separators, controller state, and + // search semantics are covered in address_conversion_test, + // hierarchy_search_controller_test, and regex_search_test. + // This file focuses exclusively on *parity* between adapters. + + group('Adapter search parity — both sources produce same results', () { + test('root name matches', () { + expect(vcdService.root.name, 'Abcd'); + expect(jsonService.root.name, 'Abcd'); + }); + + test('root.children returns same module names', () { + final vcdChildren = vcdService.root.children.map((c) => c.name).toSet(); + final jsonChildren = jsonService.root.children.map((c) => c.name).toSet(); + expect(vcdChildren, jsonChildren); + }); + + test('root.signals returns same signal names at root', () { + final vcdSigs = vcdService.root.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonService.root.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('nested node signals() returns same signal names', () { + final vcdLab = vcdService.root.children.first; + final jsonLab = jsonService.root.children.first; + final vcdSigs = vcdLab.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonLab.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('signalByAddress works on both — top level', () { + final vcdClk = _resolve(vcdService, 'Abcd/clk'); + final jsonClk = _resolve(jsonService, 'Abcd/clk'); + expect(vcdClk, isNotNull, reason: 'VCD: Abcd/clk'); + expect(jsonClk, isNotNull, reason: 'JSON: Abcd/clk'); + expect(vcdClk!.name, 'clk'); + expect(jsonClk!.name, 'clk'); + }); + + test('signalByAddress works on both — nested', () { + final vcdHit = _resolve(vcdService, 'Abcd/lab/cam/hit'); + final jsonHit = _resolve(jsonService, 'Abcd/lab/cam/hit'); + expect(vcdHit, isNotNull, reason: 'VCD: Abcd/lab/cam/hit'); + expect(jsonHit, isNotNull, reason: 'JSON: Abcd/lab/cam/hit'); + expect(vcdHit!.name, 'hit'); + expect(jsonHit!.name, 'hit'); + }); + + test('searchSignals plain query — same result names', () { + final vcdResults = + vcdService.searchSignals('clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals glob query — same result names', () { + final vcdResults = + vcdService.searchSignals('**/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('**/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals path query — same result names', () { + final vcdResults = + vcdService.searchSignals('lab/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('lab/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchModules — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + + test('searchModules nested — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + }); + + // ── Verify the external-hierarchy handoff works ── + // Individual search/address semantics are covered elsewhere. + // This group tests the adapter re-wrapping contract. + + group('External hierarchy flow (simulates DevTools → wave viewer)', () { + test('BaseHierarchyAdapter.fromTree produces identical search results', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final results = rewrapped.searchSignals('clk'); + expect(results, isNotEmpty); + expect( + results.map((r) => r.name).toSet(), + jsonService.searchSignals('clk').map((r) => r.name).toSet(), + ); + }); + + test('BaseHierarchyAdapter.fromTree preserves signalByAddress', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final hit = _resolve(rewrapped, 'Abcd/lab/cam/hit'); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + }); + }); + + group('BaseHierarchyAdapter.root', () { + test('throws StateError when root is not set', () { + final adapter = _UnsetAdapter(); + expect(() => adapter.root, throwsStateError); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/address_conversion_test.dart b/packages/rohd_hierarchy/test/address_conversion_test.dart new file mode 100644 index 000000000..2caabdef6 --- /dev/null +++ b/packages/rohd_hierarchy/test/address_conversion_test.dart @@ -0,0 +1,286 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// address_conversion_test.dart +// Tests for HierarchyService address ↔ pathname conversion methods. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Address ↔ pathname conversion', () { + late HierarchyService service; + late HierarchyOccurrence root; + + // Build a test hierarchy: + // Top + // ├─ cpu (child 0) + // │ ├─ signals: clk, rst + // │ └─ alu (child 0 of cpu) + // │ └─ signals: a, b, out + // └─ mem (child 1) + // └─ signals: addr, data + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 1, + ), + SignalOccurrence( + name: 'b', + width: 1, + ), + SignalOccurrence( + name: 'out', + width: 1, + ), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'rst', + width: 1, + ), + ], + children: [alu], + ); + + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 1, + ), + ], + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + )..buildAddresses(); + + service = BaseHierarchyAdapter.fromTree(root); + }); + + group('pathnameToAddress', () { + test('root name resolves to root address', () { + final addr = service.pathnameToAddress('Top'); + expect(addr, isNotNull); + expect(addr!.path, equals([])); + }); + + test('module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0])); + }); + + test('nested module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); + }); + + test('second child module resolves correctly', () { + final addr = service.pathnameToAddress('Top/mem'); + expect(addr, isNotNull); + expect(addr!.path, equals([1])); + }); + + test('signal path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/clk'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); // cpu[0], signal clk[0] + }); + + test('second signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/rst'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 1])); // cpu[0], signal rst[1] + }); + + test('nested signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu/out'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 2])); // cpu[0], alu[0], out[2] + }); + + test('dot-separated paths work too', () { + final addr = service.pathnameToAddress('Top.cpu.alu.b'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 1])); // cpu[0], alu[0], b[1] + }); + + test('non-existent path returns null', () { + expect(service.pathnameToAddress('Top/nonexistent'), isNull); + }); + + test('non-existent signal returns null', () { + expect(service.pathnameToAddress('Top/cpu/nonexistent'), isNull); + }); + + test('empty string returns root', () { + final addr = service.pathnameToAddress(''); + expect(addr, isNotNull); + expect(addr!.path, isEmpty); + }); + }); + + group('addressToPathname', () { + test('root address returns root name', () { + expect( + service.addressToPathname(OccurrenceAddress.root), + equals('Top'), + ); + }); + + test('module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0])), + equals('Top/cpu'), + ); + }); + + test('nested module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0, 0])), + equals('Top/cpu/alu'), + ); + }); + + test('signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0]), + asSignal: true, + ), + equals('Top/cpu/clk'), + ); + }); + + test('nested signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0, 2]), + asSignal: true, + ), + equals('Top/cpu/alu/out'), + ); + }); + + test('out-of-bounds child returns null', () { + expect( + service.addressToPathname(const OccurrenceAddress([5])), + isNull, + ); + }); + + test('out-of-bounds signal returns null', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 99]), + asSignal: true, + ), + isNull, + ); + }); + }); + + group('nodeByAddress', () { + test('root address returns root', () { + final node = service.occurrenceByAddress(OccurrenceAddress.root); + expect(node?.name, equals('Top')); + }); + + test('child address returns correct child', () { + final node = service.occurrenceByAddress(const OccurrenceAddress([0])); + expect(node?.name, equals('cpu')); + }); + + test('nested address returns correct node', () { + final node = + service.occurrenceByAddress(const OccurrenceAddress([0, 0])); + expect(node?.name, equals('alu')); + }); + + test('out-of-bounds returns null', () { + expect( + service.occurrenceByAddress(const OccurrenceAddress([99])), + isNull, + ); + }); + }); + + group('signalByAddress', () { + test('signal address returns correct signal', () { + // cpu's first signal (clk) has address [0, 0] + final clkAddr = root.children[0].signals[0].address!; + final sig = service.signalByAddress(clkAddr); + expect(sig?.name, equals('clk')); + }); + + test('nested signal address returns correct signal', () { + // alu's third signal (out) has address [0, 0, 2] + final outAddr = root.children[0].children[0].signals[2].address!; + final sig = service.signalByAddress(outAddr); + expect(sig?.name, equals('out')); + }); + + test('root address returns null (not a signal)', () { + expect(service.signalByAddress(OccurrenceAddress.root), isNull); + }); + }); + + group('waveformIdToAddress', () { + test('dot-separated waveform ID resolves', () { + final addr = service.waveformIdToAddress('Top.cpu.alu.a'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 0])); // cpu[0], alu[0], a[0] + }); + }); + + group('round-trip', () { + test('pathname → address → pathname preserves module path', () { + const path = 'Top/cpu/alu'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!); + expect(roundTripped, equals(path)); + }); + + test('pathname → address → pathname preserves signal path', () { + const path = 'Top/cpu/alu/out'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!, asSignal: true); + expect(roundTripped, equals(path)); + }); + + test('address → pathname → address preserves module address', () { + const addr = OccurrenceAddress([0, 0]); + final path = service.addressToPathname(addr); + expect(path, isNotNull); + final roundTripped = service.pathnameToAddress(path!); + expect(roundTripped?.path, equals(addr.path)); + }); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/devtools_search_flow_test.dart b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart new file mode 100644 index 000000000..ac5f11ad9 --- /dev/null +++ b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart @@ -0,0 +1,214 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_search_flow_test.dart +// Tests that simulate the DevTools embedding flow with local signal IDs: +// HierarchyNode tree → BaseHierarchyAdapter.fromTree → search +// +// 2026 April +// Author: Desmond Kirkpatrick + +// The test verifies that search works correctly with local signal IDs +// (as opposed to VCD-style full-path IDs), catching any assumption +// mismatches in the search engine. + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build the test hierarchy tree directly, matching the structure +/// that would be produced from ROHD inspector JSON. +/// Signals have local IDs and full qualified paths. +HierarchyOccurrence _buildTestHierarchy() { + final cam = HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'hit', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'entry', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'match_out', + width: 1, + direction: 'output', + ), + ], + ); + + final lab = HierarchyOccurrence( + name: 'lab', + children: [cam], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'fromUpstream_request__st', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'toUpstream_response__st', + width: 64, + direction: 'output', + ), + ], + ); + + final dmaEngine = HierarchyOccurrence( + name: 'engine', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'enable', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'data_in', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 64, + direction: 'output', + ), + SignalOccurrence( + name: 'done', + width: 1, + direction: 'output', + ), + ], + ); + + return HierarchyOccurrence( + name: 'Abcd', + children: [lab, dmaEngine], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'resetn', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'araddr_s', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'rdata_s', + width: 32, + direction: 'output', + ), + ], + ); +} + +void main() { + late BaseHierarchyAdapter service; + + setUp(() { + final root = _buildTestHierarchy()..buildAddresses(); + service = BaseHierarchyAdapter.fromTree(root); + }); + + group( + 'DevTools flow — local signal IDs ' + '→ BaseHierarchyAdapter.fromTree → search', () { + // Basic search, address, glob, and controller behavior is covered by + // hierarchy_search_controller_test, regex_search_test, + // address_conversion_test, and module_search_test. + // + // This group focuses on what is unique to the DevTools local-ID flow: + // search correctness when SignalOccurrence.name is a local name (not a full + // path). + + test('search works with local signal IDs', () { + // Plain prefix search still finds signals by name + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.map((r) => r.name), everyElement('clk')); + // Glob still works + final globResults = service.searchSignals('**/entry'); + expect(globResults, isNotEmpty); + expect(globResults.first.name, 'entry'); + }); + + test('signalByAddress resolves despite local IDs', () { + final addr = + OccurrenceAddress.tryFromPathname('Abcd/lab/cam/hit', service.root); + expect(addr, isNotNull); + final hit = service.signalByAddress(addr!); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + expect(hit.name, 'hit'); // local, not full path + expect(hit.path(), 'Abcd/lab/cam/hit'); + }); + + test('searchModules works with local-ID tree', () { + final results = service.searchOccurrences('cam'); + expect(results, isNotEmpty); + expect(results.first.occurrence.name, 'cam'); + }); + }); + + // ── SignalOccurrence ID format verification ── + + group('local signal ID format', () { + test('signals have local IDs (not full paths)', () { + final sigs = service.root.signals; + final clk = sigs.firstWhere((s) => s.name == 'clk'); + // The signal id is the local name, not the full path + expect(clk.name, 'clk'); + // But fullPath is the full qualified path + expect(clk.path(), 'Abcd/clk'); + }); + + test('local signal IDs do not break address resolution', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Abcd/lab/cam/match_out', service.root); + expect(addr, isNotNull); + final result = service.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'match_out'); + expect(result.name, 'match_out'); // local name + }); + + test('search results carry the correct signal object', () { + final results = service.searchSignals('Abcd/rdata_s'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal, isNotNull); + expect(r.signal!.name, 'rdata_s'); // local name + expect(r.signal!.path(), 'Abcd/rdata_s'); // full path + expect(r.signal!.width, 32); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/filter_bank_integration_test.dart b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart new file mode 100644 index 000000000..6a8fe173a --- /dev/null +++ b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart @@ -0,0 +1,719 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank_integration_test.dart +// Integration tests using a real ROHD FilterBank netlist JSON fixture. +// Covers model getters, service methods, and adapter edge cases. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Load the slim FilterBank fixture and build a NetlistHierarchyAdapter. +NetlistHierarchyAdapter _loadFixture() { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + return NetlistHierarchyAdapter.fromJson(json); +} + +void main() { + late NetlistHierarchyAdapter adapter; + late HierarchyService service; + + setUpAll(() { + adapter = _loadFixture(); + service = adapter; + service.root.buildAddresses(); + }); + + // ─────────────── NetlistHierarchyAdapter parsing ─────────────── + + group('NetlistHierarchyAdapter — FilterBank fixture', () { + test('top module is FilterBank', () { + expect(service.root.name, 'FilterBank'); + }); + + test('rootNameOverride replaces root node name', () { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + final custom = NetlistHierarchyAdapter.fromJson( + json, + rootNameOverride: 'MyDesign', + ); + expect(custom.root.name, 'MyDesign'); + expect(custom.root.definition, 'FilterBank'); + }); + + test('child modules preserve definition separately from instance name', () { + final controller = service.root.children.firstWhere( + (child) => child.name == 'controller_1', + ); + + expect(controller.name, 'controller_1'); + expect(controller.definition, 'FilterController'); + }); + + test('root has expected ports as signals', () { + final portNames = service.root.signals.map((s) => s.name).toSet(); + expect(portNames, containsAll(['clk', 'reset', 'start', 'done'])); + }); + + test('has hierarchical children (ch0, ch1, controller)', () { + final childNames = service.root.children.map((c) => c.name).toSet(); + // ch0_1 and ch1_1 are FilterChannel instances; controller_1 is + // FilterController + expect(childNames, containsAll(['ch0_1', 'ch1_1', 'controller_1'])); + }); + + test('primitive cells are marked isPrimitive', () { + // array_slice cells in FilterBank are $slice — primitive + final sliceCells = service.root.children.where( + (c) => c.definition != null && c.definition!.startsWith(r'$'), + ); + expect(sliceCells, isNotEmpty); + for (final cell in sliceCells) { + expect( + cell.isPrimitive, + isTrue, + reason: '${cell.name} (${cell.definition}) should be primitive', + ); + } + }); + + test('primitive cells have port signals from port_directions', () { + final primitives = service.root.children.where((c) => c.isPrimitive); + for (final prim in primitives) { + expect( + prim.signals, + isNotEmpty, + reason: '${prim.name} should have port signals', + ); + // All signals on primitive cells should have a direction + for (final s in prim.signals) { + expect( + s.isPort, + isTrue, + reason: '${prim.name}/${s.name} should be a port', + ); + expect(s.direction, isNotEmpty); + } + } + }); + + test('netnames with hide_name=1 are excluded', () { + // FilterBank has controller_1_loadingPhase with hide_name=1 + final allSignalNames = service.root.depthFirstSignals().map( + (s) => s.name, + ); + expect(allSignalNames, isNot(contains('controller_1_loadingPhase'))); + }); + + test('netnames with computed attribute are included with isComputed', () { + // CoeffBank has const_0_2_h0 with computed=1 + // Navigate: FilterBank → ch0_1 → one of its children should have + // a CoeffBank with computed signals + bool foundComputed(HierarchyOccurrence node) { + for (final s in node.signals) { + if (s.isComputed) { + return true; + } + } + return node.children.any(foundComputed); + } + + expect( + foundComputed(service.root), + isTrue, + reason: 'Should have at least one computed signal', + ); + }); + + test(r'$-prefixed netnames are excluded', () { + // Any netname starting with $ should be filtered out + final allNames = service.root.depthFirstSignals().map((s) => s.name); + final dollarNames = allNames.where((n) => n.startsWith(r'$')); + expect( + dollarNames, + isEmpty, + reason: r'No $-prefixed netnames should appear', + ); + }); + }); + + // ─────────────── HierarchyNode model getters ─────────────── + + group('HierarchyNode model getters', () { + test('ports returns only signals with direction', () { + final ports = service.root.ports; + expect(ports, isNotEmpty); + for (final p in ports) { + expect(p.isPort, isTrue); + expect(p.direction, isNotEmpty); + } + }); + + test('inputs returns only input ports', () { + final inputs = service.root.inputs; + expect(inputs, isNotEmpty); + for (final s in inputs) { + expect(s.direction, 'input'); + } + expect(inputs.map((s) => s.name), contains('clk')); + }); + + test('outputs returns only output ports', () { + final outputs = service.root.outputs; + expect(outputs, isNotEmpty); + for (final s in outputs) { + expect(s.direction, 'output'); + } + expect(outputs.map((s) => s.name), contains('done')); + }); + + test(r'isPrimitiveType is true for $-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType(r'$mux'), isTrue); + expect(HierarchyOccurrence.isPrimitiveType(r'$and'), isTrue); + }); + + test(r'isPrimitiveType is false for non-$-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType('FilterBank'), isFalse); + }); + + test('isPrimitiveType is false for empty string', () { + expect(HierarchyOccurrence.isPrimitiveType(''), isFalse); + }); + + test('isPrimitiveCell reflects isPrimitive field and type', () { + // A node marked isPrimitive=true + final primCell = service.root.children.firstWhere((c) => c.isPrimitive); + expect(primCell.isPrimitiveCell, isTrue); + + // The root module is not primitive + expect(service.root.isPrimitiveCell, isFalse); + }); + + test('depthFirstSignals places root signals first', () { + final all = service.root.depthFirstSignals(); + expect(all, isNotEmpty); + + final rootSigs = service.root.signals; + for (var i = 0; i < rootSigs.length; i++) { + expect(all[i].name, rootSigs[i].name); + } + }); + + test('depthFirstSignals count equals recursive signal total', () { + final all = service.root.depthFirstSignals(); + int countSignals(HierarchyOccurrence n) => + n.signals.length + + n.children.fold(0, (sum, c) => sum + countSignals(c)); + expect(all.length, countSignals(service.root)); + }); + }); + + // ─────────────── SignalOccurrence model getters ─────────────── + + group('SignalOccurrence model getters', () { + test('isPort is true for Port instances', () { + final port = service.root.signals.first; + expect(port.isPort, isTrue); + }); + + test('input port has isInput true and isOutput/isInout false', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + expect(clk.isPort, isTrue); + expect(clk.isInput, isTrue); + expect(clk.isOutput, isFalse); + expect(clk.isInout, isFalse); + }); + + test('output port has isOutput true and isInput false', () { + final done = service.root.signals.firstWhere((s) => s.name == 'done'); + expect(done.isOutput, isTrue); + expect(done.isInput, isFalse); + }); + + test('isPort is false for non-Port signals (internal wires)', () { + // Internal signals (from netnames) are SignalOccurrence, not Port. + // The fixture includes visible non-port netnames like tapMatch0. + final allSigs = service.root.depthFirstSignals(); + final nonPorts = allSigs.where((s) => !s.isPort).toList(); + expect( + nonPorts, + isNotEmpty, + reason: 'Should have non-Port internal signals from netnames', + ); + }); + + test('SignalOccurrence.toString includes name and width', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + final str = clk.toString(); + expect(str, contains('clk')); + }); + }); + + // ─────────────── HierarchyService methods ─────────────── + + group('HierarchyService — search coverage', () { + test('searchNodes returns HierarchyNode objects', () { + final nodes = service.matchOccurrences('controller'); + expect(nodes, isNotEmpty); + for (final n in nodes) { + expect(n, isA()); + } + }); + + test('autocompletePaths returns children for partial path', () { + final suggestions = service.autocompletePaths('FilterBank/'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s, startsWith('FilterBank/')); + } + }); + + test('autocompletePaths filters by prefix', () { + final suggestions = service.autocompletePaths('FilterBank/ch'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s.toLowerCase(), contains('/ch')); + } + }); + + test('autocompletePaths with empty string returns root', () { + final suggestions = service.autocompletePaths(''); + // Should suggest root-level completions + expect(suggestions, isNotEmpty); + }); + + test('autocompletePaths appends / for nodes with children', () { + final suggestions = service.autocompletePaths('FilterBank/'); + final withSlash = suggestions.where((s) => s.endsWith('/')); + // At least ch0_1 and ch1_1 have children + expect(withSlash, isNotEmpty); + }); + + test('hasRegexChars is false for plain text', () { + expect(HierarchyService.hasRegexChars('clk'), isFalse); + }); + + test('hasRegexChars detects * glob', () { + expect(HierarchyService.hasRegexChars('c*'), isTrue); + }); + + test('hasRegexChars detects ? glob', () { + expect(HierarchyService.hasRegexChars('cl?'), isTrue); + }); + + test('hasRegexChars detects character class', () { + expect(HierarchyService.hasRegexChars('[a-z]'), isTrue); + }); + + test('hasRegexChars detects group alternation', () { + expect(HierarchyService.hasRegexChars('(a|b)'), isTrue); + }); + + test('hasRegexChars detects + quantifier', () { + expect(HierarchyService.hasRegexChars('a+'), isTrue); + }); + + test('longestCommonPrefix finds shared prefix', () { + expect( + HierarchyService.longestCommonPrefix([ + 'FilterBank/ch0', + 'FilterBank/ch1', + ]), + 'FilterBank/ch', + ); + }); + + test('longestCommonPrefix returns null for empty list', () { + expect(HierarchyService.longestCommonPrefix([]), isNull); + }); + + test('longestCommonPrefix returns null for no common prefix', () { + expect(HierarchyService.longestCommonPrefix(['abc', 'xyz']), isNull); + }); + + test('longestCommonPrefix is case-sensitive', () { + final prefix = HierarchyService.longestCommonPrefix([ + 'Filter/abc', + 'Filter/abd', + ]); + expect(prefix, 'Filter/ab'); + }); + }); + + // ─────────────── HierarchySearchController ─────────────── + + group('HierarchySearchController — additional coverage', () { + test('selectAt selects valid index', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(0); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt clamps high index to last result', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(999); + expect(ctrl.selectedIndex, ctrl.results.length - 1); + }); + + test('selectAt clamps negative index to zero', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(-5); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt on empty results is no-op', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..selectAt(3); + expect(ctrl.selectedIndex, 0); + expect(ctrl.hasResults, isFalse); + }); + + test('tabComplete expands to longest common prefix', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + if (ctrl.results.length > 1) { + final expansion = ctrl.tabComplete('clk'); + // Expansion should be longer than the query if results share a + // common prefix beyond 'clk' + if (expansion != null) { + expect(expansion.length, greaterThan(3)); + } + } + }); + + test('tabComplete returns null when no results', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('zzz_nonexistent'); + expect(ctrl.tabComplete('zzz_nonexistent'), isNull); + }); + + test('tabComplete returns null when prefix is not longer', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + // If there's a single result whose displayPath equals normalized + // query, tabComplete should return null or the path itself. + // With multiple results from different modules, the common prefix + // may not be longer. + final result = ctrl.tabComplete(ctrl.results.first.displayPath); + // Either null or the same length — shouldn't crash + expect(result, anyOf(isNull, isA())); + }); + }); + + // ─────────────── ModuleSearchResult getters ─────────────── + + group('ModuleSearchResult — additional getters', () { + test('isModule reflects non-primitive node', () { + final results = service.searchOccurrences('ch0'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.isModule, isNotNull); + }); + + test('childCount reflects node.children.length', () { + final results = service.searchOccurrences('FilterBank'); + final fbResult = results.firstWhere( + (r) => r.path.length == 1, + orElse: () => results.first, + ); + expect(fbResult.childCount, greaterThan(0)); + }); + + test('toString includes module name', () { + final results = service.searchOccurrences('ch0'); + expect(results.first.toString(), contains('ch0')); + }); + }); + + // ─────────────── SignalSearchResult toString ─────────────── + + group('SignalSearchResult.toString', () { + test('toString includes signal name', () { + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.first.toString(), contains('clk')); + }); + }); + + // ─────────────── BaseHierarchyAdapter edge case ─────────────── + // The real uninitialized-root StateError test lives in + // coverage_gaps_test.dart. Here we just verify fromTree works. + + group('BaseHierarchyAdapter — fromTree produces usable root', () { + test('fromTree immediately sets root', () { + final tree = HierarchyOccurrence(name: 'r'); + final svc = BaseHierarchyAdapter.fromTree(tree); + expect(svc.root.name, 'r'); + }); + }); + + // ─────────────── Multiple instantiation (dedup) ─────────────── + + group('Multiple instantiation — FilterChannel dedup', () { + test('ch0 and ch1 are separate node instances', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + expect(identical(ch0, ch1), isFalse); + }); + + test('ch0 and ch1 have identical signal structure', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + expect(ch0.signals.length, ch1.signals.length); + + final ch0PortNames = ch0.signals.map((s) => s.name).toSet(); + final ch1PortNames = ch1.signals.map((s) => s.name).toSet(); + expect(ch0PortNames, ch1PortNames); + }); + + test('search finds signals in both channel instances', () { + // Both channels should have a clk port + final results = service.searchSignals('clk'); + final channelClks = results + .where( + (r) => r.signalId.contains('ch0_1') || r.signalId.contains('ch1_1'), + ) + .toList(); + // Should find clk in both ch0_1 and ch1_1 + expect( + channelClks.where((r) => r.signalId.contains('ch0_1')), + isNotEmpty, + ); + expect( + channelClks.where((r) => r.signalId.contains('ch1_1')), + isNotEmpty, + ); + }); + + test('addresses resolve independently for each instance', () { + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/clk', + service.root, + ); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/clk', + service.root, + ); + + expect(ch0Addr, isNotNull); + expect(ch1Addr, isNotNull); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'clk'); + expect(ch1Sig!.name, 'clk'); + }); + + test('both instances have internal (non-port) signals', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Internal = ch0.signals.where((s) => !s.isPort).toList(); + final ch1Internal = ch1.signals.where((s) => !s.isPort).toList(); + + expect( + ch0Internal, + isNotEmpty, + reason: 'ch0_1 should have internal signals from netnames', + ); + expect( + ch1Internal, + isNotEmpty, + reason: 'ch1_1 should have internal signals from netnames', + ); + }); + + test('both instances share the same internal signal names', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Names = + ch0.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + final ch1Names = + ch1.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + expect(ch0Names, ch1Names); + }); + + test('internal signals are addressable per-instance', () { + // validPipe exists as a netname in both FilterChannel definitions + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/validPipe', + service.root, + ); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/validPipe', + service.root, + ); + + expect(ch0Addr, isNotNull, reason: 'ch0_1/validPipe should resolve'); + expect(ch1Addr, isNotNull, reason: 'ch1_1/validPipe should resolve'); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'validPipe'); + expect(ch1Sig!.name, 'validPipe'); + expect(ch0Sig.isPort, isFalse); + }); + + test('search finds internal signals in both instances', () { + final results = service.searchSignals('validPipe'); + final inCh0 = results.where((r) => r.signalId.contains('ch0_1')); + final inCh1 = results.where((r) => r.signalId.contains('ch1_1')); + expect(inCh0, isNotEmpty, reason: 'validPipe should be found in ch0_1'); + expect(inCh1, isNotEmpty, reason: 'validPipe should be found in ch1_1'); + }); + + test('depthFirstSignals includes internal signals from both instances', () { + final all = service.root.depthFirstSignals(); + final vpSigs = all.where((s) => s.name == 'validPipe').toList(); + expect( + vpSigs.length, + greaterThanOrEqualTo(2), + reason: 'validPipe should appear in at least ch0 and ch1', + ); + }); + }); + + // ─────────────── InOut (bidirectional) port tests ─────────────── + + group('InOut port — dataBus', () { + test('root has dataBus as inout port', () { + final dataBus = service.root.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect(dataBus, isNotNull, reason: 'FilterBank should have dataBus'); + expect(dataBus!.direction, 'inout'); + expect(dataBus.isInout, isTrue); + expect(dataBus.isInput, isFalse); + expect(dataBus.isOutput, isFalse); + }); + + test('inputs getter excludes inout ports', () { + final inputs = service.root.inputs; + final inoutInInputs = inputs.where((s) => s.direction == 'inout'); + expect( + inoutInInputs, + isEmpty, + reason: 'inputs should not include inout ports', + ); + }); + + test('outputs getter excludes inout ports', () { + final outputs = service.root.outputs; + final inoutInOutputs = outputs.where((s) => s.direction == 'inout'); + expect( + inoutInOutputs, + isEmpty, + reason: 'outputs should not include inout ports', + ); + }); + + test('inouts getter returns only inout ports', () { + final inouts = service.root.inouts; + expect(inouts, isNotEmpty, reason: 'inouts should include dataBus'); + expect(inouts.map((s) => s.name), contains('dataBus')); + for (final s in inouts) { + expect(s.direction, 'inout'); + } + }); + + test('ports getter includes inout ports', () { + final allPorts = service.root.ports; + final inouts = allPorts.where((p) => p.direction == 'inout').toList(); + expect(inouts, isNotEmpty, reason: 'ports should include inout ports'); + expect(inouts.first.name, 'dataBus'); + }); + + test('dataBus is addressable and resolvable', () { + final addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/dataBus', + service.root, + ); + expect(addr, isNotNull, reason: 'dataBus should be addressable'); + + final sig = service.signalByAddress(addr!); + expect(sig, isNotNull); + expect(sig!.name, 'dataBus'); + expect(sig.isInout, isTrue); + }); + + test('search finds dataBus inout port', () { + final results = service.searchSignals('dataBus'); + expect(results, isNotEmpty); + final dataBusResults = results.where( + (r) => r.signalId.contains('dataBus'), + ); + expect(dataBusResults, isNotEmpty); + }); + + test('SharedDataBus child also has dataBus inout', () { + final sharedBus = service.root.children + .where((c) => c.name == 'sharedBus_1') + .firstOrNull; + expect( + sharedBus, + isNotNull, + reason: 'sharedBus_1 cell should be present', + ); + final childDataBus = sharedBus!.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect( + childDataBus, + isNotNull, + reason: 'SharedDataBus should have dataBus inout', + ); + expect(childDataBus!.isInout, isTrue); + }); + + test('depthFirstSignals includes inout ports', () { + final all = service.root.depthFirstSignals(); + final inouts = all.where((s) => s.isInout); + expect( + inouts, + isNotEmpty, + reason: 'depthFirstSignals should include inout ports', + ); + }); + + test('addressToPathname round-trips for inout signal', () { + final addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/dataBus', + service.root, + ); + expect(addr, isNotNull); + final pathname = service.addressToPathname(addr!, asSignal: true); + expect(pathname, 'FilterBank/dataBus'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/fixtures/filter_bank.json b/packages/rohd_hierarchy/test/fixtures/filter_bank.json new file mode 100644 index 000000000..494318612 --- /dev/null +++ b/packages/rohd_hierarchy/test/fixtures/filter_bank.json @@ -0,0 +1,1183 @@ +{ + "modules": { + "CoeffBank_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "tapIndex": { + "direction": "input", + "bits": [ + 2, + 3 + ] + }, + "coeffArray": { + "direction": "input", + "bits": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51 + ] + }, + "coeffOut": { + "direction": "output", + "bits": [ + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67 + ] + } + }, + "netnames": { + "tapMatch0": { + "bits": [ + 68 + ], + "attributes": {} + }, + "tapMatch1": { + "bits": [ + 69 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 103, + 104 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "mux_3": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_3": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_0_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_1_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "MacUnit_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "coeffIn": { + "direction": "input", + "bits": [ + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ] + }, + "accumIn": { + "direction": "input", + "bits": [ + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 50 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 51 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 52 + ] + }, + "result": { + "direction": "output", + "bits": [ + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68 + ] + } + }, + "netnames": { + "sampleIn_stage2_i": { + "bits": [ + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84 + ], + "attributes": {} + }, + "sampleIn_stage0_o": { + "bits": [ + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attributes": {} + } + }, + "cells": { + "comb_stage2_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage2_i": "input", + "_in2_coeffIn_stage2_i": "input", + "_in4_accumIn_stage2_i": "input", + "_out1_sampleIn_stage2": "output", + "_out3_coeffIn_stage2": "output", + "_out5_accumIn_stage2": "output" + } + }, + "ff_sampleIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_sampleIn_stage0_o": "input", + "_in5_sampleIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_sampleIn_stage1_i": "output", + "_out7_sampleIn_stage2_i": "output" + } + }, + "comb_stage0_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage0_i": "input", + "_in2_coeffIn_stage0_i": "input", + "_in4_accumIn_stage0_i": "input", + "_in6_product": "input", + "_out1_sampleIn_stage0": "output", + "_out3_coeffIn_stage0": "output", + "_out5_accumIn_stage0": "output", + "_out7_sampleIn_stage0": "output" + } + }, + "multiply_1": { + "type": "$mul", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "ff_coeffIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_coeffIn_stage0_o": "input", + "_in5_coeffIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_coeffIn_stage1_i": "output", + "_out7_coeffIn_stage2_i": "output" + } + } + } + }, + "FilterChannel_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "FilterController": { + "attributes": { + "src": "generated" + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "inputValid": { + "direction": "input", + "bits": [ + 5 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 6 + ] + }, + "filterEnable": { + "direction": "output", + "bits": [ + 7 + ] + }, + "loadingPhase": { + "direction": "output", + "bits": [ + 8 + ] + }, + "doneFlag": { + "direction": "output", + "bits": [ + 9 + ] + }, + "state": { + "direction": "output", + "bits": [ + 10, + 11, + 12 + ] + } + }, + "netnames": { + "currentState": { + "bits": [ + 13, + 14, + 15 + ], + "attributes": {} + }, + "isDraining": { + "bits": [ + 16 + ], + "attributes": {} + }, + "const_0_3_h3": { + "bits": [ + 94, + 95, + 96 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_1": { + "type": "Combinational", + "port_directions": { + "_in0_currentState": "input", + "_in1_FilterState_idle": "input", + "_in6_start": "input", + "_in9_FilterState_loading": "input", + "_in14_inputValid": "input", + "_in17_FilterState_running": "input", + "_in22_inputDone": "input", + "_in25_FilterState_draining": "input", + "_in30_drainDone": "input", + "_in33_FilterState_done": "input", + "_out42_filterEnable": "output", + "_out43_loadingPhase": "output", + "_out44_doneFlag": "output", + "_out45_nextState": "output" + } + }, + "swizzle_1": { + "type": "$buf", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "equals_2": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_2": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_isDraining": "input", + "_in4__drainCount_add_const_1": "input", + "_trigger0_clk": "input", + "_out7_drainCount": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "FilterChannel_T3_W16_0": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "SharedDataBus": { + "attributes": { + "src": "generated" + }, + "ports": { + "writeEnable": { + "direction": "input", + "bits": [ + 502 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 503 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 504 + ] + }, + "storedValue": { + "direction": "output", + "bits": [ + 505, + 506, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 516, + 517, + 518, + 519, + 520 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 521, + 522, + 523, + 524, + 525, + 526, + 527, + 528, + 529, + 530, + 531, + 532, + 533, + 534, + 535, + 536 + ] + } + }, + "netnames": { + "latch": { + "bits": [ + 537, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 546, + 547, + 548, + 549, + 550, + 551, + 552 + ], + "attributes": {} + } + }, + "cells": {} + }, + "FilterBank": { + "attributes": { + "src": "generated", + "top": 1 + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "samplesIn": { + "direction": "input", + "bits": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 37 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 38 + ] + }, + "channelOut": { + "direction": "output", + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 71 + ] + }, + "done": { + "direction": "output", + "bits": [ + 72 + ] + }, + "state": { + "direction": "output", + "bits": [ + 73, + 74, + 75 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 600, + 601, + 602, + 603, + 604, + 605, + 606, + 607, + 608, + 609, + 610, + 611, + 612, + 613, + 614, + 615 + ] + } + }, + "netnames": { + "channelOut_0_": { + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54 + ], + "attributes": {} + }, + "sample0_data": { + "bits": [ + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162 + ], + "attributes": {} + }, + "controller_1_loadingPhase": { + "bits": [ + 146 + ], + "hide_name": 1, + "attributes": {} + } + }, + "cells": { + "ch0_1": { + "type": "FilterChannel_T3_W16_0", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "controller_1": { + "type": "FilterController", + "port_directions": { + "clk": "input", + "reset": "input", + "start": "input", + "inputValid": "input", + "inputDone": "input", + "filterEnable": "output", + "loadingPhase": "output", + "doneFlag": "output", + "state": "output" + } + }, + "ch1_1": { + "type": "FilterChannel_T3_W16", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "array_slice_3": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "array_slice_4": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "sharedBus_1": { + "type": "SharedDataBus", + "port_directions": { + "writeEnable": "input", + "clk": "input", + "reset": "input", + "storedValue": "output", + "dataBus": "inout" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart new file mode 100644 index 000000000..9c574df79 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart @@ -0,0 +1,194 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_path_vs_signal_id_test.dart +// Verifies that signal.path(separator:) and search result signalId +// work correctly with different separators. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('signal.path() with separator', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + SignalOccurrence? resolve(String path) { + final addr = OccurrenceAddress.tryFromPathname(path, adapter.root); + return addr != null ? adapter.signalByAddress(addr) : null; + } + + test('resolves dot-separated IDs', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.clk'); + }); + + test('resolves slash-separated IDs', () { + final s = resolve('abcd/clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves with exact case', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves nested dot-separated IDs', () { + final s = resolve('abcd.lab.data'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.lab.data'); + }); + + test('resolves nested slash-separated IDs', () { + final s = resolve('abcd/lab/data'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/lab/data'); + }); + + test('searchSignals returns result with resolved signal', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + } + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signal!.path(), 'abcd/clk'); + expect(clkResult.signal!.path(separator: '.'), 'abcd.clk'); + }); + + test('searchSignals signalId is walker-built (slash) path', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signalId, 'abcd/clk'); + }); + + test('signal.path() matches signalId with default separator', () { + final results = adapter.searchSignals('clk'); + final result = results.first; + expect(result.signal, isNotNull); + expect(result.signal!.path(), result.signalId); + }); + + test('searchSignalsRegex returns result with signal resolved', () { + final results = adapter.searchSignalsRegex('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + expect(r.signal!.path(), contains('/')); + } + }); + }); + + group('ROHD slash-separated hierarchy', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'Top', + signals: [SignalOccurrence(name: 'clk', width: 1)], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [SignalOccurrence(name: 'data_out', width: 8)], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + test('ROHD signals: path() matches signalId (both slash)', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal!.path(), r.signalId); + }); + }); + + group('SignalOccurrence as port', () { + test('creates a port signal with defaults', () { + final p = SignalOccurrence(name: 'clk', width: 1, direction: 'input'); + expect(p.name, 'clk'); + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port signal with explicit overrides', () { + final p = SignalOccurrence( + name: 'data', + direction: 'output', + width: 32, + isComputed: true, + ); + HierarchyOccurrence(name: 'Top', signals: [p]).buildAddresses(); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.direction, 'output'); + expect(p.path(), 'Top/data'); + expect(p.parent!.path(), 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('SignalOccurrence.value', () { + test('value is null by default', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = SignalOccurrence(name: 'a', width: 8, value: 'ff'); + expect(s.value, 'ff'); + }); + }); + + group('SignalOccurrence.parent', () { + test('parent is null before buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.parent, isNull); + }); + + test('parent is set after buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + HierarchyOccurrence( + name: 'Top', + children: [ + HierarchyOccurrence(name: 'sub', signals: [s]) + ], + ).buildAddresses(); + expect(s.parent!.path(), 'Top/sub'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_query_test.dart b/packages/rohd_hierarchy/test/hierarchy_query_test.dart new file mode 100644 index 000000000..7425aaa43 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_query_test.dart @@ -0,0 +1,655 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query_test.dart +// Tests for PrefixQuery and RegexQuery matching logic. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build a test hierarchy: +/// +/// ```text +/// SoC +/// ├─ signals: [clk, reset, irq0, irq1] +/// ├─ cpu0 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ ├─ regfile +/// │ │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// │ └─ decoder +/// │ └─ signals: [opcode, enable, mode] +/// ├─ cpu1 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ └─ regfile +/// │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// ├─ mem_ctrl +/// │ ├─ signals: [clk, reset, addr, data_in, data_out, valid] +/// │ ├─ ch0 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ ├─ ch1 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ └─ ch2 +/// │ └─ signals: [clk, addr, data, hit, miss] +/// └─ io_mux +/// ├─ signals: [clk, sel, data_muxed, valid_muxed] +/// ├─ uart0 +/// │ └─ signals: [clk, tx, rx, baud_sel] +/// └─ uart1 +/// └─ signals: [clk, tx, rx, baud_sel] +/// ``` +HierarchyService buildTestHierarchy() { + HierarchyOccurrence mkAlu() => HierarchyOccurrence( + name: 'alu', + definition: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + SignalOccurrence(name: 'overflow', width: 1), + ], + ); + + HierarchyOccurrence mkRegfile() => HierarchyOccurrence( + name: 'regfile', + definition: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + SignalOccurrence(name: 'wr_en', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'decoder', + definition: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + SignalOccurrence(name: 'mode', width: 2), + ], + ); + + final cpu0 = HierarchyOccurrence( + name: 'cpu0', + definition: 'CPU', + children: [mkAlu(), mkRegfile(), decoder], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + final cpu1 = HierarchyOccurrence( + name: 'cpu1', + definition: 'CPU', + children: [mkAlu(), mkRegfile()], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + HierarchyOccurrence mkCacheChannel(String name) => HierarchyOccurrence( + name: name, + definition: 'CacheChannel', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'miss', width: 1), + ], + ); + + final memCtrl = HierarchyOccurrence( + name: 'mem_ctrl', + definition: 'MemController', + children: [ + mkCacheChannel('ch0'), + mkCacheChannel('ch1'), + mkCacheChannel('ch2') + ], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data_in', width: 32), + SignalOccurrence(name: 'data_out', width: 32), + SignalOccurrence(name: 'valid', width: 1), + ], + ); + + HierarchyOccurrence mkUart(String name) => HierarchyOccurrence( + name: name, + definition: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + SignalOccurrence(name: 'baud_sel', width: 3), + ], + ); + + final ioMux = HierarchyOccurrence( + name: 'io_mux', + definition: 'IOMux', + children: [mkUart('uart0'), mkUart('uart1')], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'sel', width: 2), + SignalOccurrence(name: 'data_muxed', width: 8), + SignalOccurrence(name: 'valid_muxed', width: 1), + ], + ); + + final root = HierarchyOccurrence( + name: 'SoC', + definition: 'SoC', + children: [cpu0, cpu1, memCtrl, ioMux], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'irq0', width: 1), + SignalOccurrence(name: 'irq1', width: 1), + ], + ); + + return BaseHierarchyAdapter.fromTree(root); +} + +void main() { + late HierarchyService svc; + + setUpAll(() { + svc = buildTestHierarchy(); + }); + + // ═══════════════════════════════════════════════════════════════ + // PrefixQuery + // ═══════════════════════════════════════════════════════════════ + + group('PrefixQuery', () { + group('matchOccurrence', () { + test('matches occurrence name containing segment', () { + final q = PrefixQuery('cpu'); + // 'cpu0' contains 'cpu' + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + + test('returns empty set when no match', () { + final q = PrefixQuery('mem'); + expect(q.matchOccurrence('cpu0', 0), isEmpty); + }); + + test('past end of segments returns current state', () { + final q = PrefixQuery('cpu'); + // stateIndex == segmentCount → already consumed + expect(q.matchOccurrence('anything', 1), equals({1})); + }); + + test('multi-segment: advances one segment at a time', () { + final q = PrefixQuery('cpu/alu'); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + expect(q.matchOccurrence('alu', 1), equals({2})); + // 'regfile' doesn't match 'alu' + expect(q.matchOccurrence('regfile', 1), isEmpty); + }); + + test('dot separator treated as slash', () { + final q = PrefixQuery('cpu.alu'); + expect(q.segmentCount, equals(2)); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + }); + + group('matchSignal', () { + test('matches signal name with startsWith', () { + final q = PrefixQuery('cpu/clk'); + // At state 1 (after matching 'cpu'), 'clk' starts with 'clk' + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('clk_gated', 1), isTrue); + }); + + test('does not match signal for non-last segment', () { + final q = PrefixQuery('cpu/alu/res'); + // At state 1, there are still 2 segments left → only last matches + expect(q.matchSignal('result', 1), isFalse); + // At state 2, this is the last segment + expect(q.matchSignal('result', 2), isTrue); + }); + + test('past end matches any signal', () { + final q = PrefixQuery('cpu'); + expect(q.matchSignal('anything', 1), isTrue); + }); + }); + + group('isComplete', () { + test('complete when stateIndex >= segmentCount', () { + final q = PrefixQuery('cpu/alu'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + expect(q.isComplete(3), isTrue); + }); + }); + + group('isEmpty', () { + test('empty for blank query', () { + expect(PrefixQuery('').isEmpty, isTrue); + expect(PrefixQuery(' ').isEmpty, isTrue); + }); + + test('not empty for real query', () { + expect(PrefixQuery('clk').isEmpty, isFalse); + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(PrefixQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to occurrences', () { + final q = PrefixQuery('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // RegexQuery + // ═══════════════════════════════════════════════════════════════ + + group('RegexQuery', () { + group('exact name matching', () { + test('matches exact occurrence name', () { + final q = RegexQuery('SoC/cpu0/alu'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('alu', 2), equals({3})); + }); + + test('does not match wrong name', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('cpu1', 1), isEmpty); + }); + + test('case sensitive', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + }); + }); + + group('glob wildcard *', () { + test('star matches any characters', () { + final q = RegexQuery('SoC/cpu*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + + test('star at start', () { + final q = RegexQuery('SoC/*_ctrl'); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('star in middle', () { + final q = RegexQuery('SoC/io_*'); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + expect(q.matchOccurrence('io_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('standalone star matches anything', () { + final q = RegexQuery('SoC/*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + }); + }); + + group('glob wildcard ?', () { + test('question mark matches one character', () { + final q = RegexQuery('SoC/cpu?'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + // 'cpuXY' is two chars after 'cpu' → no match + expect(q.matchOccurrence('cpuXY', 1), isEmpty); + }); + }); + + group('glob-star ** (cross hierarchy boundaries)', () { + test('** matches zero levels', () { + final q = RegexQuery('SoC/**/alu'); + // ** at index 1 can match zero levels → try index 2 ('alu') + // directly against children of SoC + final states = q.matchOccurrence('alu', 1); + // Should include state 1 (stay at **) and possibly skip to 2 + expect(states, contains(1)); + }); + + test('** matches one or more levels', () { + final q = RegexQuery('SoC/**/clk'); + // ** stays at ** when consuming a node + expect(q.matchOccurrence('cpu0', 1), contains(1)); + expect(q.matchOccurrence('alu', 1), contains(1)); + }); + + test('** followed by exact segment', () { + final q = RegexQuery('SoC/**/alu'); + // At state 1 (**), 'alu' should match both staying and advancing + final states = q.matchOccurrence('alu', 1); + expect(states, contains(1)); // stay at ** + expect(states, contains(3)); // skip ** + match 'alu' → index 3 + }); + + test('isComplete with trailing **', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(1), isTrue); // ** can match zero + expect(q.isComplete(2), isTrue); // past end + }); + }); + + group('character classes [...]', () { + test('matches character range', () { + final q = RegexQuery('SoC/**/d[0-9]+'); + // Signal matching + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d1', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('fixed character set', () { + final q = RegexQuery('SoC/mem_ctrl/ch[012]'); + expect(q.matchOccurrence('ch0', 2), equals({3})); + expect(q.matchOccurrence('ch1', 2), equals({3})); + expect(q.matchOccurrence('ch2', 2), equals({3})); + expect(q.matchOccurrence('ch3', 2), isEmpty); + }); + }); + + group('alternation (...|...)', () { + test('matches either alternative', () { + final q = RegexQuery('SoC/**/(clk|reset)'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isTrue); + expect(q.matchSignal('data', 2), isFalse); + }); + + test('alternation on occurrences', () { + final q = RegexQuery('SoC/(cpu0|cpu1)'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + }); + + group('regex quantifiers', () { + test('{n,m} repetition', () { + final q = RegexQuery('SoC/**/d[0-9]{1,2}'); + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + // 'd123' has 3 digits → no match (anchored) + expect(q.matchSignal('d123', 2), isFalse); + }); + + test('+ one or more', () { + final q = RegexQuery('SoC/**/irq[0-9]+'); + expect(q.matchSignal('irq0', 2), isTrue); + expect(q.matchSignal('irq1', 2), isTrue); + expect(q.matchSignal('irq', 2), isFalse); + }); + }); + + group('matchSignal', () { + test('exact signal name', () { + final q = RegexQuery('SoC/cpu0/clk'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isFalse); + }); + + test('glob * on signal', () { + final q = RegexQuery('SoC/cpu0/alu/*'); + expect(q.matchSignal('a', 3), isTrue); + expect(q.matchSignal('result', 3), isTrue); + }); + + test('glob * prefix on signal', () { + final q = RegexQuery('SoC/**/carry_*'); + expect(q.matchSignal('carry_out', 2), isTrue); + expect(q.matchSignal('overflow', 2), isFalse); + }); + + test('** then signal matches all signals when past segments', () { + final q = RegexQuery('SoC/**'); + // At state 1 (**), isComplete is true → match all signals + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('anything', 1), isTrue); + }); + + test('signal does not match non-terminal segment', () { + // SoC/cpu0/alu/result — 'result' is segment index 3, last segment + final q = RegexQuery('SoC/cpu0/alu/result'); + // At state 2, there's still 'result' to match → not last-terminal + expect(q.matchSignal('result', 2), isFalse); + // At state 3 it is the last segment + expect(q.matchSignal('result', 3), isTrue); + }); + }); + + group('isComplete', () { + test('complete when past all segments', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + }); + + test('complete with trailing glob-stars', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isTrue); // ** matches zero + }); + + test('not complete with remaining regex segments', () { + final q = RegexQuery('SoC/**/alu'); + expect(q.isComplete(1), isFalse); // ** then 'alu' remains + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(RegexQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to both', () { + final q = RegexQuery('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Factory constructors on HierarchyQuery + // ═══════════════════════════════════════════════════════════════ + + group('HierarchyQuery factories', () { + test('.prefix creates PrefixQuery', () { + final q = HierarchyQuery.prefix('cpu/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(2)); + }); + + test('.regex creates RegexQuery', () { + final q = HierarchyQuery.regex('SoC/**/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(3)); + }); + + test('.prefix with target', () { + final q = HierarchyQuery.prefix('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + + test('.regex with target', () { + final q = HierarchyQuery.regex('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Edge cases + // ═══════════════════════════════════════════════════════════════ + + group('Edge cases', () { + test('empty query is isEmpty', () { + expect(HierarchyQuery.prefix('').isEmpty, isTrue); + expect(HierarchyQuery.regex('').isEmpty, isTrue); + expect(HierarchyQuery.prefix(' ').isEmpty, isTrue); + expect(HierarchyQuery.regex(' ').isEmpty, isTrue); + }); + + test('PrefixQuery with only separators', () { + final q = PrefixQuery('///'); + expect(q.segmentCount, equals(0)); + expect(q.isEmpty, isFalse); // raw string isn't blank + expect(q.isComplete(0), isTrue); // no segments to match + }); + + test('RegexQuery single segment', () { + final q = RegexQuery('clk'); + expect(q.segmentCount, equals(1)); + expect(q.matchSignal('clk', 0), isTrue); + expect(q.matchSignal('reset', 0), isFalse); + }); + + test('RegexQuery multiple consecutive glob-stars', () { + final q = RegexQuery('SoC/**/**/clk'); + // Should still work — multiple **'s just redundantly match zero+ + expect(q.isComplete(1), isFalse); // **/** then clk + final states = q.matchOccurrence('cpu0', 1); + expect(states, contains(1)); // stay at first ** + }); + + test('RegexQuery with .* explicit regex', () { + final q = RegexQuery('SoC/**/.*mux.*'); + expect(q.matchOccurrence('io_mux', 2), equals({3})); + expect(q.matchSignal('data_muxed', 2), isTrue); + expect(q.matchSignal('valid_muxed', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('PrefixQuery crossesBoundaries is false', () { + expect(PrefixQuery('x').crossesBoundaries, isFalse); + }); + + test('RegexQuery crossesBoundaries is false (uses ** explicitly)', () { + expect(RegexQuery('SoC/**/clk').crossesBoundaries, isFalse); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Integration: queries against the real hierarchy via + // HierarchyService (to verify the contract makes sense) + // ═══════════════════════════════════════════════════════════════ + + group('Integration with hierarchy (signal path search)', () { + test('PrefixQuery segments match existing search', () { + // Verify PrefixQuery produces the same segments as + // the existing searchSignalPaths logic. + final q = PrefixQuery('cpu/alu/res'); + final paths = svc.searchSignalPaths('cpu/alu/res'); + // Both cpu0 and cpu1 have ALU with 'result' + expect(paths.length, equals(2)); + for (final p in paths) { + expect(p, contains('result')); + } + // Verify the query matches the same way + expect(q.matchOccurrence('cpu0', 0), isNotEmpty); + expect(q.matchOccurrence('alu', 1), isNotEmpty); + expect(q.matchSignal('result', 2), isTrue); + }); + + test('RegexQuery glob matches existing regex search', () { + // SoC/**/clk should find clk at many levels + final paths = svc.searchSignalPathsRegex('SoC/**/clk'); + // clk exists at: SoC, cpu0, cpu1, cpu0/regfile, cpu1/regfile, + // mem_ctrl, ch0, ch1, ch2, io_mux, uart0, uart1 = 12 total + expect(paths.length, equals(12)); + for (final p in paths) { + expect(p, endsWith('/clk')); + } + }); + + test('RegexQuery character class matches indexed signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/d[0-9]+'); + // d0, d1, d2, d15 in cpu0/regfile and cpu1/regfile = 8 total + expect(paths.length, equals(8)); + for (final p in paths) { + expect(p, matches(RegExp(r'/d\d+$'))); + } + }); + + test('RegexQuery alternation matches specific signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/(tx|rx)'); + // tx and rx in uart0 and uart1 = 4 total + expect(paths.length, equals(4)); + }); + + test('RegexQuery ch[0-2] matches channel occurrences', () { + final paths = svc.searchOccurrencePathsRegex('SoC/mem_ctrl/ch[0-2]'); + expect(paths.length, equals(3)); + expect( + paths, + containsAll([ + 'SoC/mem_ctrl/ch0', + 'SoC/mem_ctrl/ch1', + 'SoC/mem_ctrl/ch2', + ])); + }); + + test('RegexQuery *_mux matches occurrence by suffix', () { + final paths = svc.searchOccurrencePathsRegex('SoC/*_mux'); + expect(paths.length, equals(1)); + expect(paths.first, equals('SoC/io_mux')); + }); + + test('RegexQuery .*mux.* matches signals containing mux', () { + final paths = svc.searchSignalPathsRegex('SoC/**/.*mux.*'); + expect(paths, contains('SoC/io_mux/data_muxed')); + expect(paths, contains('SoC/io_mux/valid_muxed')); + }); + + test('PrefixQuery finds irq signals at root', () { + final paths = svc.searchSignalPaths('SoC/irq'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/irq0')); + expect(paths, contains('SoC/irq1')); + }); + + test('RegexQuery baud_sel across both UARTs', () { + final paths = svc.searchSignalPathsRegex('SoC/**/baud_sel'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/io_mux/uart0/baud_sel')); + expect(paths, contains('SoC/io_mux/uart1/baud_sel')); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart new file mode 100644 index 000000000..bd7801f2a --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart @@ -0,0 +1,505 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller_test.dart +// Tests for HierarchySearchController. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Minimal hierarchy for testing the controller with a real +/// HierarchyService. +HierarchyOccurrence _buildTestTree() => HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence(name: 'data_in', width: 8), + SignalOccurrence(name: 'data_out', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence(name: 'a', width: 16), + SignalOccurrence(name: 'b', width: 16), + SignalOccurrence(name: 'result', width: 16), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence(name: 'addr', width: 32), + ], + ), + ], + ); + +void main() { + late BaseHierarchyAdapter hierarchy; + late HierarchySearchController signalCtrl; + late HierarchySearchController moduleCtrl; + + setUp(() { + hierarchy = BaseHierarchyAdapter.fromTree(_buildTestTree()); + signalCtrl = HierarchySearchController.forSignals(hierarchy); + moduleCtrl = HierarchySearchController.forOccurrences(hierarchy); + }); + + group('HierarchySearchController — signal search', () { + test('starts with empty state', () { + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + expect(signalCtrl.currentSelection, isNull); + }); + + test('updateQuery populates results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'clk'); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery with empty string clears results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + + signalCtrl.updateQuery(''); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery resets selectedIndex', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); // index 1 + expect(signalCtrl.selectedIndex, 1); + + signalCtrl.updateQuery('data'); // re-search + expect(signalCtrl.selectedIndex, 0); // reset + }); + + test('normalise converts dots to slashes', () { + signalCtrl.updateQuery('cpu.alu.a'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'a'); + }); + + test('counterText is correct', () { + signalCtrl.updateQuery('data'); + expect(signalCtrl.counterText, '1/${signalCtrl.results.length}'); + + signalCtrl.selectNext(); + expect(signalCtrl.counterText, '2/${signalCtrl.results.length}'); + }); + + test('currentSelection returns the highlighted result', () { + signalCtrl.updateQuery('data'); + final first = signalCtrl.currentSelection; + expect(first, isNotNull); + expect(first!.name, 'data_in'); + + signalCtrl.selectNext(); + expect(signalCtrl.currentSelection!.name, 'data_out'); + }); + + test('selectNext wraps around', () { + signalCtrl.updateQuery('data'); + final count = signalCtrl.results.length; + expect(count, greaterThan(1)); + + for (var i = 0; i < count; i++) { + signalCtrl.selectNext(); + } + expect(signalCtrl.selectedIndex, 0); // wrapped + }); + + test('selectPrevious wraps around', () { + signalCtrl + ..updateQuery('data') + ..selectPrevious(); // wraps from 0 → last + expect(signalCtrl.selectedIndex, signalCtrl.results.length - 1); + }); + + test('selectNext/selectPrevious no-op when empty', () { + signalCtrl.selectNext(); + expect(signalCtrl.selectedIndex, 0); + signalCtrl.selectPrevious(); + expect(signalCtrl.selectedIndex, 0); + }); + + test('clear resets everything', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.selectedIndex, greaterThan(0)); + + signalCtrl.clear(); + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.currentSelection, isNull); + }); + + test('no results for non-matching query', () { + signalCtrl.updateQuery('xyz_no_match'); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + }); + + test('plain query uses prefix match, not substring', () { + // 'a' should match signals starting with 'a' (addr, a), + // but NOT signals that merely contain 'a' (data_in, data_out). + signalCtrl.updateQuery('a'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('a')); // Top/cpu/alu/a + expect(names, contains('addr')); // Top/mem/addr + expect(names, isNot(contains('data_in'))); // 'a' is not a prefix + expect(names, isNot(contains('data_out'))); + }); + + test('glob * pattern routes to regex search', () { + // 'cpu/*_out' should match signals ending in '_out' under cpu + // (single-segment globs like '*_out' only search root-level; + // use a path segment to target a child module). + signalCtrl.updateQuery('cpu/*_out'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('data_out')); + expect(names, isNot(contains('data_in'))); + }); + + test('glob * at end matches prefix', () { + signalCtrl.updateQuery('cpu/data*'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob * at root matches top-level signals', () { + // Single-segment glob only searches root module signals. + signalCtrl.updateQuery('*st'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('rst')); + }); + }); + + group('HierarchySearchController — module search', () { + test('finds modules by name', () { + moduleCtrl.updateQuery('cpu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'cpu'); + }); + + test('finds nested modules', () { + moduleCtrl.updateQuery('alu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('counterText and selection work for modules', () { + moduleCtrl.updateQuery('m'); // matches 'mem', possibly others + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.counterText, isNotEmpty); + expect(moduleCtrl.currentSelection, isNotNull); + }); + }); + + group('scrollOffsetToReveal', () { + test('returns null when item is visible', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 2, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 0, + ); + // item at 96..144, viewport 0..300 → visible + expect(offset, isNull); + }); + + test('scrolls up when item is above viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 0, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 100, + ); + // item at 0..48, viewport starts at 100 → need to scroll to 0 + expect(offset, 0.0); + }); + + test('scrolls down when item is below viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 10, + itemHeight: 48, + viewportHeight: 200, + currentOffset: 0, + ); + // item at 480..528, viewport 0..200 → scroll to 528-200 = 328 + expect(offset, 328.0); + }); + + test('returns null when item is at bottom edge', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 4, + itemHeight: 50, + viewportHeight: 250, + currentOffset: 0, + ); + // item at 200..250, viewport 0..250 → exactly visible + expect(offset, isNull); + }); + }); + + // ------------------------------------------------------------------ + // VCD-style dot-separated paths + // ------------------------------------------------------------------ + group('VCD dot-separated paths', () { + late BaseHierarchyAdapter vcdHierarchy; + + setUp(() { + // VCD/FST files produce dot-separated IDs like "testbench.childA.clk" + vcdHierarchy = BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'testbench', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'childA', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'data', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'out', width: 4), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'childB', + signals: [ + SignalOccurrence(name: 'enable', width: 1), + ], + ), + ], + ), + ); + }); + + test('searchSignalPaths with slash query finds dot-separated signal', () { + // User types "childA/clk" — walker normalises to hierarchySeparator + final results = vcdHierarchy.searchSignalPaths('childA/clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignalPaths with slash query finds deep signal', () { + final results = vcdHierarchy.searchSignalPaths('childA/sub/out'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/sub/out')); + }); + + test('searchSignals with slash query finds dot-separated signal', () { + final results = vcdHierarchy.searchSignals('childA/clk'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childA/clk'); + }); + + test('searchModules with slash query finds dot-separated module', () { + final results = vcdHierarchy.searchOccurrences('childA'); + expect(results, isNotEmpty); + expect(results.first.occurrence.path(), 'testbench/childA'); + }); + + test('searchSignalPaths with dot query still works', () { + // Dots in query are treated as separators too + final results = vcdHierarchy.searchSignalPaths('childA.clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignals with glob on dot-separated paths', () { + // Glob wildcard should work across dot-separated IDs + final results = vcdHierarchy.searchSignals('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + final ids = results.map((r) => r.signal!.path()).toSet(); + expect(ids, contains('testbench/clk')); + expect(ids, contains('testbench/childA/clk')); + }); + + test('searchSignals with single segment on dot-separated paths', () { + // Single segment search should use startsWith + final results = vcdHierarchy.searchSignals('ena'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childB/enable'); + }); + + test('controller forSignals works with dot-separated hierarchy', () { + final ctrl = + HierarchySearchController.forSignals(vcdHierarchy) + ..updateQuery('childA/clk'); + expect(ctrl.results, isNotEmpty); + expect(ctrl.results.first.signal!.path(), 'testbench/childA/clk'); + }); + }); + + // ------------------------------------------------------------------ + // DevTools flow — hierarchy with local signal IDs + // ------------------------------------------------------------------ + group('DevTools flow — local signal IDs → BaseHierarchyAdapter.fromTree', () { + late BaseHierarchyAdapter rohdHierarchy; + late HierarchySearchController rohdSignalCtrl; + late HierarchySearchController rohdModuleCtrl; + + setUp(() { + // Build a tree with local signal IDs (not full paths) — this is the key + // difference from the VCD path where IDs are full paths. + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'b', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'result', + width: 16, + direction: 'output', + ), + ], + ); + final cpu = HierarchyOccurrence( + name: 'cpu', + children: [alu], + signals: [ + SignalOccurrence( + name: 'data_in', + width: 8, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 8, + direction: 'output', + ), + ], + ); + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 32, + direction: 'input', + ), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'rst', + width: 1, + direction: 'input', + ), + ], + )..buildAddresses(); + rohdHierarchy = BaseHierarchyAdapter.fromTree(root); + rohdSignalCtrl = HierarchySearchController.forSignals(rohdHierarchy); + rohdModuleCtrl = HierarchySearchController.forOccurrences(rohdHierarchy); + }); + + test('signal IDs are local (not full paths)', () { + final rootSigs = rohdHierarchy.root.signals; + final clk = rootSigs.firstWhere((s) => s.name == 'clk'); + expect(clk.name, 'clk'); + expect(clk.path(), 'Top/clk'); + }); + + test('updateQuery finds signals despite local IDs', () { + rohdSignalCtrl.updateQuery('clk'); + expect(rohdSignalCtrl.hasResults, isTrue); + expect(rohdSignalCtrl.results.first.name, 'clk'); + }); + + test('signalByAddress works with full path', () { + final addr = + OccurrenceAddress.tryFromPathname('Top/clk', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'clk'); + }); + + test('signalByAddress works with nested path', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Top/cpu/alu/a', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'a'); + }); + + test('path-based search narrows to module', () { + rohdSignalCtrl.updateQuery('cpu/data'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob search works', () { + rohdSignalCtrl.updateQuery('**/a'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, contains('a')); + }); + + test('module search works', () { + rohdModuleCtrl.updateQuery('alu'); + expect(rohdModuleCtrl.hasResults, isTrue); + expect(rohdModuleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('search results match VCD-style tree results', () { + // The SAME queries should produce the same signal NAMES as + // the manually-built tree (VCD path), even though signal IDs differ. + rohdSignalCtrl.updateQuery('data'); + final rohdNames = rohdSignalCtrl.results.map((r) => r.name).toSet(); + + signalCtrl.updateQuery('data'); + final vcdNames = signalCtrl.results.map((r) => r.name).toSet(); + + expect(rohdNames, vcdNames, + reason: 'Same query should find same signals regardless of source'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/module_search_test.dart b/packages/rohd_hierarchy/test/module_search_test.dart new file mode 100644 index 000000000..9339492cf --- /dev/null +++ b/packages/rohd_hierarchy/test/module_search_test.dart @@ -0,0 +1,325 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_search_test.dart +// Tests for module tree search functionality using hierarchy API. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Module Tree Search - HierarchyService', () { + late HierarchyOccurrence root; + late HierarchyService hierarchy; + + setUpAll(() { + // Create a test hierarchy + // Top + // CPU (2 children) + // ALU + // Decoder + // Memory + // ControlUnit + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + final controlUnit = HierarchyOccurrence( + name: 'ControlUnit', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, controlUnit], + ); + + // Use BaseHierarchyAdapter.fromTree to convert to HierarchyService + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('root node is accessible', () { + expect(hierarchy.root.name, equals('Top')); + expect(hierarchy.root.isPrimitive, isFalse); + }); + + test('children of root are accessible', () { + final children = hierarchy.root.children; + expect(children, isNotEmpty); + expect(children.length, equals(3)); + expect(children.any((c) => c.name == 'CPU'), isTrue); + }); + + test('searchNodePaths finds CPU module', () { + final results = hierarchy.searchOccurrencePaths('CPU'); + expect(results, isNotEmpty, + reason: 'Should find CPU module by simple name'); + expect(results.any((path) => path.contains('CPU')), isTrue); + }); + + test('searchNodePaths finds ALU with hierarchical query', () { + final results = hierarchy.searchOccurrencePaths('CPU/ALU'); + expect(results, isNotEmpty, + reason: 'Should find ALU with hierarchical path'); + expect(results.any((path) => path.contains('ALU')), isTrue); + }); + + test('searchNodePaths works with dot notation', () { + final results = hierarchy.searchOccurrencePaths('Top.CPU.ALU'); + expect(results, isNotEmpty, reason: 'Should find ALU with dot notation'); + expect(results.any((path) => path.contains('Top/CPU/ALU')), isTrue); + }); + + test('searchNodePaths limits results', () { + final results = hierarchy.searchOccurrencePaths('', limit: 2); + expect(results.length, lessThanOrEqualTo(2), + reason: 'Should respect limit parameter'); + }); + + test('searchModules returns ModuleSearchResult objects', () { + final results = hierarchy.searchOccurrences('Memory'); + expect(results, isNotEmpty); + expect(results.first, isA()); + expect(results.first.name, equals('Memory')); + expect(results.first.isModule, isTrue); + }); + + test('searchModules result contains full metadata', () { + final results = hierarchy.searchOccurrences('Decoder'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.occurrenceId, contains('Decoder')); + expect(result.path, isNotEmpty); + expect(result.path.last, equals('Decoder')); + expect(result.occurrence, isNotNull); + }); + + test('searchNodePaths returns empty for non-matching query', () { + final results = hierarchy.searchOccurrencePaths('nonexistent'); + expect(results, isEmpty, + reason: 'Should return empty list for non-matching query'); + }); + + test('searchNodePaths returns empty for empty query', () { + final results = hierarchy.searchOccurrencePaths(''); + expect(results, isEmpty, + reason: 'Should return empty list for empty query'); + }); + + test('searchModules finds modules at different depths', () { + // Should find both Top and Top/CPU + final results = hierarchy.searchOccurrences('Top'); + expect(results.length, greaterThanOrEqualTo(1)); + expect(results.any((r) => r.name == 'Top'), isTrue); + }); + }); + + group('Module Search - Hierarchical Matching', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Create a deeper hierarchy to test matching + // Design + // ProcessingUnit + // DataPath + // Multiplier + // Adder + // Controller + // Memory + // RAM + // Cache + + final multiplier = HierarchyOccurrence( + name: 'Multiplier', + ); + + final adder = HierarchyOccurrence( + name: 'Adder', + ); + + final dataPath = HierarchyOccurrence( + name: 'DataPath', + children: [multiplier, adder], + ); + + final controller = HierarchyOccurrence( + name: 'Controller', + ); + + final processingUnit = HierarchyOccurrence( + name: 'ProcessingUnit', + children: [dataPath, controller], + ); + + final ram = HierarchyOccurrence( + name: 'RAM', + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [ram, cache], + ); + + final root = HierarchyOccurrence( + name: 'Design', + children: [processingUnit, memory], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('single segment matches at any level', () { + final results = hierarchy.searchOccurrencePaths('Multiplier'); + expect(results, isNotEmpty, + reason: 'Should find Multiplier even without full path'); + expect(results.any((r) => r.endsWith('Multiplier')), isTrue); + }); + + test('two segment path matches correctly', () { + final results = hierarchy.searchOccurrencePaths('DataPath/Multiplier'); + expect(results.any((r) => r.contains('DataPath/Multiplier')), isTrue, + reason: 'Should find Multiplier under DataPath'); + }); + + test('full hierarchical path matches precisely', () { + final results = + hierarchy.searchOccurrencePaths('ProcessingUnit/DataPath/Adder'); + expect(results.any((r) => r.contains('ProcessingUnit/DataPath/Adder')), + isTrue, + reason: 'Should find Adder with full hierarchical path'); + }); + + test('partial name matching works', () { + final results1 = hierarchy.searchOccurrencePaths('Path'); + expect(results1.any((r) => r.contains('DataPath')), isTrue, + reason: 'Should match partial "path" in DataPath'); + + final results2 = hierarchy.searchOccurrencePaths('Unit'); + expect(results2.any((r) => r.contains('ProcessingUnit')), isTrue, + reason: 'Should match partial "unit" in ProcessingUnit'); + }); + }); + + group('Module Search - Integration with Tree Filtering', () { + late HierarchyOccurrence root; + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory], + ); + }); + + test('hierarchical filtering shows root when descendant matches', () { + final matchesSearch = _filterNodeRecursive(root, 'alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown because descendant matches'); + }); + + test('hierarchical filtering shows parent of matching child', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown because child ALU matches'); + }); + + test('hierarchical filtering hides node without matching descendants', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden because no ALU descendant'); + }); + + test('path separator search shows root for hierarchical match', () { + final matchesSearch = _filterNodeRecursive(root, 'cpu/alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown for hierarchical search'); + }); + + test('path separator search shows matching parent', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'cpu/alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown for hierarchical search'); + }); + + test('path separator search hides non-matching subtree', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'cpu/alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden for non-matching path'); + }); + }); +} + +/// Helper function to simulate tree filtering with hierarchical search. +/// Matches query against node name using hierarchical logic. +bool _filterNodeRecursive(HierarchyOccurrence node, String query) { + final queryParts = query + .replaceAll('.', '/') + .toLowerCase() + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _matchesHierarchicalQuery(node, queryParts, 0); +} + +bool _matchesHierarchicalQuery( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx].toLowerCase(); + final nodeName = node.name.toLowerCase(); + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + for (final child in node.children) { + if (_matchesHierarchicalQuery(child, queryParts, nextQueryIdx)) { + return true; + } + } + + return false; +} diff --git a/packages/rohd_hierarchy/test/occurrence_address_test.dart b/packages/rohd_hierarchy/test/occurrence_address_test.dart new file mode 100644 index 000000000..aafde9050 --- /dev/null +++ b/packages/rohd_hierarchy/test/occurrence_address_test.dart @@ -0,0 +1,335 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address_test.dart +// Unit tests for OccurrenceAddress class. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('OccurrenceAddress', () { + test('child() appends module index', () { + final addr = OccurrenceAddress.root.child(0).child(2).child(4); + expect(addr.path, equals([0, 2, 4])); + }); + + test('signal() appends signal index', () { + final addr = const OccurrenceAddress([0, 1]).signal(5); + expect(addr.path, equals([0, 1, 5])); + }); + + test('equality and hashcode work correctly', () { + const addr1 = OccurrenceAddress([0, 2, 4]); + const addr2 = OccurrenceAddress([0, 2, 4]); + const addr3 = OccurrenceAddress([0, 2, 5]); + + expect(addr1, equals(addr2)); + expect(addr1.hashCode, equals(addr2.hashCode)); + expect(addr1, isNot(equals(addr3))); + expect(addr1.hashCode, isNot(equals(addr3.hashCode))); + }); + + test('toString() returns debug string', () { + expect(OccurrenceAddress.root.toString(), equals('[ROOT]')); + expect(const OccurrenceAddress([0, 2, 4]).toString(), equals('[0.2.4]')); + }); + + test('toDotString() returns dot-separated path', () { + expect(OccurrenceAddress.root.toDotString(), equals('')); + expect(const OccurrenceAddress([0]).toDotString(), equals('0')); + expect(const OccurrenceAddress([0, 2, 4]).toDotString(), equals('0.2.4')); + expect( + const OccurrenceAddress([10, 200]).toDotString(), equals('10.200')); + }); + + test('fromDotString() parses dot-separated path', () { + expect( + OccurrenceAddress.fromDotString(''), equals(OccurrenceAddress.root)); + expect(OccurrenceAddress.fromDotString('0'), + equals(const OccurrenceAddress([0]))); + expect(OccurrenceAddress.fromDotString('0.2.4'), + equals(const OccurrenceAddress([0, 2, 4]))); + expect(OccurrenceAddress.fromDotString('10.200'), + equals(const OccurrenceAddress([10, 200]))); + }); + + test('toDotString/fromDotString round-trip', () { + final testCases = [ + OccurrenceAddress.root, + const OccurrenceAddress([0]), + const OccurrenceAddress([5, 10, 15]), + const OccurrenceAddress([0, 0, 0]), + const OccurrenceAddress([255]), + const OccurrenceAddress([0, 1, 2, 3, 4, 5]), + ]; + for (final original in testCases) { + final dot = original.toDotString(); + final restored = OccurrenceAddress.fromDotString(dot); + expect(restored, equals(original), reason: 'Failed for $original'); + } + }); + }); + + group('OccurrenceAddress with HierarchyNode integration', () { + late HierarchyOccurrence root; + + setUp(() { + // Build a simple tree structure + final child0 = HierarchyOccurrence( + name: 'child_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + SignalOccurrence( + name: 'sig1', + width: 8, + ), + ], + ); + + final grandchild = HierarchyOccurrence( + name: 'grandchild_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + ], + ); + + final child1 = HierarchyOccurrence( + name: 'child_1', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 4, + ), + ], + ); + + child0.children.add(grandchild); + + root = HierarchyOccurrence( + name: 'root', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + ], + children: [child0, child1], + ) + // Build addresses for all nodes + ..buildAddresses(); + }); + + test('buildAddresses assigns address to root', () { + expect(root.address, equals(OccurrenceAddress.root)); + }); + + test('buildAddresses assigns addresses to all nodes', () { + expect(root.children[0].address, equals(const OccurrenceAddress([0]))); + expect(root.children[1].address, equals(const OccurrenceAddress([1]))); + expect(root.children[0].children[0].address, + equals(const OccurrenceAddress([0, 0]))); + }); + + test('buildAddresses assigns addresses to all signals', () { + // Root signals + expect(root.signals[0].address, equals(const OccurrenceAddress([0]))); + + // Child signals + expect(root.children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0]))); + expect(root.children[0].signals[1].address, + equals(const OccurrenceAddress([0, 1]))); + + // Grandchild signals + expect(root.children[0].children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0, 0]))); + }); + }); + + group('HierarchyOccurrence.parent', () { + test('parent is null for root', () { + final root = HierarchyOccurrence(name: 'Top')..buildAddresses(); + expect(root.parent, isNull); + }); + + test('parent is set for child nodes after buildAddresses', () { + final child = HierarchyOccurrence(name: 'sub'); + final root = HierarchyOccurrence(name: 'Top', children: [child]) + ..buildAddresses(); + expect(child.parent, same(root)); + expect(child.path(), 'Top/sub'); + }); + }); + + group('HierarchyOccurrence.definition', () { + test('type is null when not provided', () { + final n = HierarchyOccurrence(name: 'a'); + expect(n.definition, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyOccurrence(name: 'a', definition: 'Counter'); + expect(n.definition, 'Counter'); + }); + }); + + group('isPrimitive on nodes', () { + test('default isPrimitive is false', () { + final n = HierarchyOccurrence(name: 'sub'); + expect(n.isPrimitive, isFalse); + }); + }); + + group('buildAddresses ports-first ordering', () { + test('ports get lower signal indices than internal signals', () { + final root = HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'internal_a', width: 8), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'internal_b', width: 4), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + final byName = {for (final s in root.signals) s.name: s}; + + // Ports should get indices 0 and 1 + expect(byName['clk']!.address, equals(const OccurrenceAddress([0]))); + expect(byName['out']!.address, equals(const OccurrenceAddress([1]))); + + // Internal signals get indices 2 and 3 + expect( + byName['internal_a']!.address, equals(const OccurrenceAddress([2]))); + expect( + byName['internal_b']!.address, equals(const OccurrenceAddress([3]))); + }); + + test('portIndex matches signal address index', () { + final root = HierarchyOccurrence( + name: 'Mod', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'b', width: 1, direction: 'input', portIndex: 1), + SignalOccurrence( + name: 'y', width: 1, direction: 'output', portIndex: 2), + SignalOccurrence(name: 'net0', width: 1), + ], + )..buildAddresses(); + + for (final s in root.signals) { + if (s.isPort) { + // portIndex should equal the last element of the address path + expect(s.address!.path.last, equals(s.portIndex), + reason: '${s.name}: portIndex=${s.portIndex} ' + 'but address index=${s.address!.path.last}'); + } + } + }); + + test('portCount returns correct count', () { + final occ = HierarchyOccurrence( + name: 'X', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'b', width: 1), + SignalOccurrence( + name: 'c', width: 1, direction: 'output', portIndex: 1), + ], + ); + expect(occ.portCount, equals(2)); + }); + + test('all-ports occurrence: indices match list order', () { + final occ = HierarchyOccurrence( + name: 'Buf', + signals: [ + SignalOccurrence( + name: 'in', width: 8, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('all-internal occurrence: indices unchanged', () { + final occ = HierarchyOccurrence( + name: 'Internal', + signals: [ + SignalOccurrence(name: 'x', width: 1), + SignalOccurrence(name: 'y', width: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('nested: ports-first ordering applies at every level', () { + final child = HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'net', width: 1), + SignalOccurrence( + name: 'p', width: 1, direction: 'input', portIndex: 0), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [child], + signals: [ + SignalOccurrence(name: 'net_top', width: 1), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + ], + )..buildAddresses(); + + // Root: clk (port) at 0, net_top (internal) at 1 + final rootByName = {for (final s in root.signals) s.name: s}; + expect(rootByName['clk']!.address!.path.last, equals(0)); + expect(rootByName['net_top']!.address!.path.last, equals(1)); + + // Child: p (port) at 0, net (internal) at 1 + final childByName = {for (final s in child.signals) s.name: s}; + expect(childByName['p']!.address!.path.last, equals(0)); + expect(childByName['net']!.address!.path.last, equals(1)); + }); + }); + + group('SignalOccurrence.portIndex', () { + test('portIndex is null for internal signals', () { + final s = SignalOccurrence(name: 'net', width: 1); + expect(s.portIndex, isNull); + expect(s.isPort, isFalse); + }); + + test('portIndex is set for port signals', () { + final s = SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + portIndex: 3, + ); + expect(s.portIndex, equals(3)); + expect(s.isPort, isTrue); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/regex_search_test.dart b/packages/rohd_hierarchy/test/regex_search_test.dart new file mode 100644 index 000000000..51f134f21 --- /dev/null +++ b/packages/rohd_hierarchy/test/regex_search_test.dart @@ -0,0 +1,546 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_search_test.dart +// Tests for regex-based hierarchy search. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Regex search - HierarchyService', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build a test hierarchy: + // + // Top + // CPU + // ALU signals: [a, b, result, carry_out] + // Decoder signals: [opcode, enable] + // RegFile signals: [clk, reset, d0, d1, d2, d15] + // Memory + // Cache signals: [clk, addr, data, hit] + // DRAM signals: [clk, cas, ras] + // IO + // UART signals: [clk, tx, rx] + // signals (Top): [clk, reset] + + final alu = HierarchyOccurrence( + name: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + ], + ); + + final regFile = HierarchyOccurrence( + name: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, regFile], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + ], + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'cas', width: 1), + SignalOccurrence(name: 'ras', width: 1), + ], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + ], + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'data_m', width: 8), + SignalOccurrence(name: 'addr_m', width: 16), + SignalOccurrence(name: 'flag_m', width: 1), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + // ── Exact match ── + + test('exact path matches single signal', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/result'); + expect(results, contains('Top/CPU/ALU/result')); + expect(results.length, 1); + }); + + test('dot in regex pattern is treated as regex metachar, not separator', + () { + // In regex mode, `.` is NOT a hierarchy separator — only `/` is. + // `Top.CPU` is a single segment meaning "Top" + any char + "CPU". + final results = hierarchy.searchSignalPathsRegex('Top.CPU.ALU.result'); + // No match because the hierarchy root is "Top", not "Top.CPU.ALU" + expect(results, isEmpty); + }); + + // ── Wildcard at one level ── + + test('.* matches all signals in a module', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('.* matches all children at a module level', () { + final results = hierarchy.searchSignalPathsRegex('Top/.*/clk'); + // Should match CPU/RegFile/clk but not deeper (** would be needed + // for that). .* represents any single-level child of Top. + // Top has children CPU, Memory, IO — none of them have clk directly + // (Top's own signals aren't "children"). Actually let's check: + // Top/.*/clk means: Top / (any child) / clk as signal + // That doesn't match because clk is in deeper modules. + // This should return empty for signals one level below Top. + expect(results, isEmpty); + }); + + test('.* matches modules at one level for signal search', () { + // Top/CPU/.*/clk — matches ALU, Decoder, RegFile; only RegFile has clk + final results = hierarchy.searchSignalPathsRegex('Top/CPU/.*/clk'); + expect(results, contains('Top/CPU/RegFile/clk')); + expect(results.length, 1); + }); + + // ── Glob-star ** ── + + test('** matches signals at any depth', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + // Top's own clk is also accessible through ** matching zero levels + expect(results, contains('Top/clk')); + }); + + test('** at beginning matches everything', () { + final results = hierarchy.searchSignalPathsRegex('**/clk'); + // All clk signals anywhere + expect(results.length, greaterThanOrEqualTo(5)); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + }); + + test('** between levels matches across boundaries', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/**/d0'); + expect(results, contains('Top/CPU/RegFile/d0')); + expect(results.length, 1); + }); + + test('** with regex signal pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/d[0-9]+'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + // ── Regex character classes ── + + test('character class in signal name', () { + final results = + hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d[0-2]'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + ])); + expect(results, isNot(contains('Top/CPU/RegFile/d15'))); + }); + + // ── Alternation ── + + test('alternation in signal name', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/(?:clk|reset)'); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/reset', + 'Top/CPU/RegFile/clk', + 'Top/CPU/RegFile/reset', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + expect(results.length, 7); + }); + + test('alternation in module name', () { + final results = hierarchy.searchSignalPathsRegex('Top/(CPU|IO)/.*/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/IO/UART/clk', + ])); + }); + + // ── Module search ── + + test('searchOccurrencePathsRegex finds modules', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/CPU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU', + 'Top/CPU/Decoder', + 'Top/CPU/RegFile', + ])); + }); + + test('searchOccurrencePathsRegex with **', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/**/DRAM'); + expect(results, contains('Top/Memory/DRAM')); + }); + + // ── Enriched results ── + + test('searchSignalsRegex returns SignalSearchResult objects', () { + final results = hierarchy.searchSignalsRegex('Top/CPU/ALU/result'); + expect(results.length, 1); + // signalId uses the normalised hierarchySeparator ('/') format + // from the tree walker — findSignalById normalises both '.' and '/'. + expect(results.first.signalId, 'Top/CPU/ALU/result'); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'result'); + }); + + test('searchSignalsRegex returns results with SignalOccurrence objects', + () { + final results = hierarchy.searchSignalsRegex('Top/**/carry_out'); + expect(results.length, 1); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'carry_out'); + expect(results.first.signal!.width, 1); + }); + + test('searchOccurrencesRegex returns OccurrenceSearchResult objects', () { + final results = hierarchy.searchOccurrencesRegex('Top/**/Cache'); + expect(results.length, 1); + expect(results.first.occurrenceId, 'Top/Memory/Cache'); + }); + + // ── Limit ── + + test('limit controls maximum results', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/.+', limit: 3); + expect(results.length, 3); + }); + + // ── Glob-style wildcards ── + + test('glob * at start matches suffix pattern', () { + // User's scenario: "*m" should match signals ending in "m". + final results = hierarchy.searchSignalPathsRegex('Top/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + test('glob * at end matches prefix pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d*'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + test('glob * in the middle matches infix pattern', () { + // *d*a* should match names containing 'd' followed eventually by 'a' + final results = + hierarchy.searchSignalPathsRegex('Top/Memory/Cache/*d*a*'); + expect(results, contains('Top/Memory/Cache/data')); + }); + + test('glob * matches all signals (like .*)', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('glob * in module level matches any child', () { + final results = hierarchy.searchSignalPathsRegex('Top/*/clk'); + // Top's immediate module-children are CPU, Memory, IO — none of + // them have a direct clk signal, so this is empty. + expect(results, isEmpty); + }); + + test('glob * combined with ** for deep search', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + // ── Empty / no match ── + + test('empty pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex(''), isEmpty); + expect(hierarchy.searchOccurrencePathsRegex(''), isEmpty); + }); + + test('non-matching pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex('Top/NonExistent/foo'), isEmpty); + }); + + // ── ** at various positions ── + + test('trailing ** collects all signals below', () { + final results = hierarchy.searchSignalPathsRegex('Top/Memory/**'); + // Should collect all signals in Memory subtree + expect( + results, + containsAll([ + 'Top/Memory/Cache/clk', + 'Top/Memory/Cache/addr', + 'Top/Memory/Cache/data', + 'Top/Memory/Cache/hit', + 'Top/Memory/DRAM/clk', + 'Top/Memory/DRAM/cas', + 'Top/Memory/DRAM/ras', + ])); + expect(results.length, 7); + }); + + test('multiple ** segments work', () { + final results = + hierarchy.searchSignalPathsRegex('**/(CPU|Memory)/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + ])); + }); + }); + + group('searchOccurrences dispatches to regex', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build hierarchy: + // Top + // CPU + // ALU + // Decoder + // MuxUnit + // Memory + // Cache + // DRAM + // IO + // UART + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final muxUnit = HierarchyOccurrence( + name: 'MuxUnit', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, muxUnit], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchOccurrences with glob pattern finds modules', () { + // Pattern: *Mux* should find MuxUnit (auto-prepended with **/) + final results = hierarchy.searchOccurrences('*Mux*'); + expect(results, isNotEmpty, + reason: + 'searchOccurrences should dispatch to regex for glob patterns'); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with ** finds deep modules', () { + final results = hierarchy.searchOccurrences('**/*Mux*'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with .* matches at one level', () { + // */.* matches any child one level below root + final results = hierarchy.searchOccurrences('*/.*/.*'); + expect(results.length, greaterThanOrEqualTo(3), + reason: 'Should match ALU, Decoder, MuxUnit, Cache, DRAM, UART'); + }); + + test('searchOccurrences with explicit path pattern', () { + // */CPU/.* matches children of CPU + final results = hierarchy.searchOccurrences('*/CPU/.*'); + expect(results.length, 3); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'Decoder'), isTrue); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with alternation', () { + final results = hierarchy.searchOccurrences('**/(ALU|DRAM)'); + expect(results.length, 2); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'DRAM'), isTrue); + }); + + test('searchOccurrences without regex uses plain matching', () { + // Plain query without glob chars uses substring matching + final results = hierarchy.searchOccurrences('Mux'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with leading **/ is not double-prepended', () { + final results = hierarchy.searchOccurrences('**/UART'); + expect(results.length, 1); + expect(results.first.name, 'UART'); + }); + + test('searchOccurrences with leading */ is not double-prepended', () { + final results = hierarchy.searchOccurrences('*/CPU'); + expect(results.length, 1); + expect(results.first.name, 'CPU'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart new file mode 100644 index 000000000..fa22a21b4 --- /dev/null +++ b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_signal_resolve_test.dart +// Tests for resolving ROHD dot-separated signal IDs. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + late HierarchyOccurrence root; + late BaseHierarchyAdapter adapter; + + setUpAll(() { + root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'resetn', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + ); + + adapter = BaseHierarchyAdapter.fromTree(root); + root.buildAddresses(); + }); + + SignalOccurrence? resolve(String dotPath) { + final addr = OccurrenceAddress.tryFromPathname(dotPath, root); + if (addr == null) { + return null; + } + return adapter.signalByAddress(addr); + } + + group('findSignalById resolves ROHD dot-separated signal IDs', () { + test('resolves top-level clk', () { + final sig = resolve('abcd.clk'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/clk'); + }); + + test('resolves top-level resetn', () { + final sig = resolve('abcd.resetn'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/resetn'); + }); + + test('resolves top-level arvalid_s', () { + final sig = resolve('abcd.arvalid_s'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/arvalid_s'); + }); + + test('resolves nested sub.data', () { + final sig = resolve('abcd.sub.data'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/sub/data'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/signal_search_result_test.dart b/packages/rohd_hierarchy/test/signal_search_result_test.dart new file mode 100644 index 000000000..2669c859f --- /dev/null +++ b/packages/rohd_hierarchy/test/signal_search_result_test.dart @@ -0,0 +1,236 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result_test.dart +// Tests for SignalSearchResult and ModuleSearchResult display helpers. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('SignalSearchResult display helpers', () { + test('displayPath strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.displayPath, equals('counter/clk')); + }); + + test('displayPath for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displayPath for single-segment path', () { + const result = SignalSearchResult( + signalId: 'clk', + path: ['clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displaySegments strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.displaySegments, equals(['sub1', 'sub2', 'clk'])); + }); + + test('intermediateOccurrenceNames extracts middle segments', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.intermediateOccurrenceNames, equals(['sub1', 'sub2'])); + }); + + test('intermediateOccurrenceNames empty for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + + test('intermediateOccurrenceNames empty for single-level nesting', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/clk', + path: ['Top', 'sub1', 'clk'], + ); + // sub1 is both the containing block and an intermediate instance + expect(result.intermediateOccurrenceNames, equals(['sub1'])); + }); + + test('name returns last path segment', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.name, equals('clk')); + }); + + test('equality based on signalId', () { + const a = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + const b = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('HierarchySearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu.clk'), + equals('top/cpu/clk'), + ); + }); + + test('preserves slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top/cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles mixed separators', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles empty query', () { + expect(HierarchySearchResult.normalizeQuery(''), equals('')); + }); + }); + + group('ModuleSearchResult display helpers', () { + late HierarchyOccurrence aluNode; + + setUp(() { + aluNode = HierarchyOccurrence( + name: 'ALU', + ); + }); + + test('displayPath strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displayPath, equals('CPU/ALU')); + }); + + test('displaySegments strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displaySegments, equals(['CPU', 'ALU'])); + }); + + test('displayPath for single-segment path', () { + final topNode = HierarchyOccurrence( + name: 'Top', + ); + final result = OccurrenceSearchResult( + occurrenceId: 'Top', + path: const ['Top'], + occurrence: topNode, + ); + expect(result.displayPath, equals('Top')); + }); + + test('equality based on moduleId', () { + final a = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + final b = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('ModuleSearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu'), + equals('top/cpu'), + ); + }); + }); + + group('searchSignals integration with display helpers', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build: Top -> counter (with clk, data[8] signals) + final counter = HierarchyOccurrence( + name: 'counter', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 8, + ), + ], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [counter], + signals: [ + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchSignals returns enriched results', () { + final results = hierarchy.searchSignals('clk'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.signalId, contains('clk')); + expect(result.displayPath, equals('counter/clk')); + expect(result.intermediateOccurrenceNames, equals(['counter'])); + }); + + test('searchSignals for top-level port', () { + final results = hierarchy.searchSignals('reset'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.displayPath, equals('reset')); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + }); +} diff --git a/rohd_devtools_extension/.vscode/launch.json b/rohd_devtools_extension/.vscode/launch.json index d80e58185..fae1b0be8 100644 --- a/rohd_devtools_extension/.vscode/launch.json +++ b/rohd_devtools_extension/.vscode/launch.json @@ -30,5 +30,16 @@ "--dart-define=use_simulated_environment=true" ], }, + { + "name": "Run: Web Standalone (port 9099)", + "request": "launch", + "type": "dart", + "program": "lib/main_standalone.dart", + "deviceId": "web-server", + "args": [ + "--web-port=9099", + "--web-hostname=0.0.0.0" + ] + } ] } \ No newline at end of file diff --git a/rohd_devtools_extension/.vscode/tasks.json b/rohd_devtools_extension/.vscode/tasks.json new file mode 100644 index 000000000..1d5530e98 --- /dev/null +++ b/rohd_devtools_extension/.vscode/tasks.json @@ -0,0 +1,137 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run: Web Standalone (Debug, port 9099)", + "type": "shell", + "command": "flutter run -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in debug mode on port 9099" + }, + { + "label": "Run: Web Standalone (Release, port 9099)", + "type": "shell", + "command": "flutter run --release -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in release mode on port 9099" + }, + { + "label": "Run: Linux Standalone (Debug)", + "type": "shell", + "command": "flutter run -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode" + }, + { + "label": "Run: Linux Standalone (Debug, software rendering)", + "type": "shell", + "command": "flutter run -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode with software rendering" + }, + { + "label": "Run: Linux Standalone (Release)", + "type": "shell", + "command": "flutter run --release -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode" + }, + { + "label": "Run: Linux Standalone (Release, software rendering)", + "type": "shell", + "command": "flutter run --release -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode with software rendering" + } + ] +} diff --git a/rohd_devtools_extension/LICENSE b/rohd_devtools_extension/LICENSE new file mode 100644 index 000000000..cfbbee995 --- /dev/null +++ b/rohd_devtools_extension/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (C) 2021-2023 Intel Corporation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rohd_devtools_extension/Makefile b/rohd_devtools_extension/Makefile new file mode 100644 index 000000000..19c7ac8ca --- /dev/null +++ b/rohd_devtools_extension/Makefile @@ -0,0 +1,23 @@ +# Minimal build entrypoints for the upstream standalone extension package. + +ROOT := $(shell pwd) + +.PHONY: all linux clean-linux help + +all: linux + +linux: + @flutter pub get + @flutter build linux --target=lib/main_standalone.dart + +clean-linux: + -rm -rf $(ROOT)/build/linux + -rm -rf $(ROOT)/linux/flutter/ephemeral + +help: + @echo "ROHD DevTools Extension" + @echo "" + @echo "Targets:" + @echo " all - Build the Linux standalone app (default)" + @echo " linux - Run flutter pub get and build Linux standalone" + @echo " clean-linux - Remove Linux build outputs" \ No newline at end of file diff --git a/rohd_devtools_extension/README.md b/rohd_devtools_extension/README.md index 9ead23c94..acb09c486 100644 --- a/rohd_devtools_extension/README.md +++ b/rohd_devtools_extension/README.md @@ -1,25 +1,83 @@ -# ROHD Devtool +# ROHD DevTools Extension -The ROHD Devtool provides debugging functionality for hardware designers. Initial proposals and discussions for the devtool can be found at . +The ROHD DevTools extension provides debugging support for ROHD hardware +designers. It connects to a running Dart VM, reads the ROHD module hierarchy, +and displays live signal information while the debugged program is paused. -How to Use the ROHD Devtool: +Initial proposals and discussions for the devtool can be found at +. -1. Set a breakpoint on your ROHD design. -2. When the breakpoint is hit, an URL will be outputted. -3. Run the dart devtools command on your terminal. -4. A webpage will open, and you can paste the URL into the webpage. -5. Look for the tab labeled 'ROHD'. +## Opening from Flutter DevTools -## Contributions +The normal user flow is through Flutter DevTools: + +1. Start debugging a ROHD program. +2. Stop at a breakpoint or otherwise pause the debugged program. +3. Use **Open DevTools in Browser** from VS Code. +4. In the browser DevTools page, open the **ROHD** tab. + +See the Flutter DevTools documentation for the surrounding DevTools workflow: +. + +When opened this way, the extension runs inside Flutter DevTools and uses VS +Code's Dart Tooling Daemon (DTD) integration to attach to and control the +debugged Dart VM. + +## Standalone Release Mode -We welcome contributions to the development of the ROHD Devtool. Please refer to our Contributing doc for guidance on how to get started. +The extension can also run as a standalone app. This is useful when you want to +connect directly to a Dart VM service URI, discover running VMs through a DTD +URI from the app's connection form, and select the specific debug VM to which to +attach. -## Running Tests on the Devtool +Run the release web standalone form: + +```sh +cd rohd_devtools_extension +flutter run --release -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart +``` + +Run the release Linux standalone form: + +```sh +cd rohd_devtools_extension +flutter run --release -d linux lib/main_standalone.dart +``` -The ROHD Devtool runs in an iframe, which means that the --platform chrome flag is required to ensure tests are run in the browser. +If the Linux build needs software rendering, use: -```cmd -flutter test --platform chrome Optional[test\modules\tree_structure\model_tree_card_test.dart] > test_output.txt +```sh +cd rohd_devtools_extension +flutter run --release -d linux --enable-software-rendering lib/main_standalone.dart ``` -This command will output the test results to a text file named `test_output.txt`. +The repository's `.vscode/tasks.json` contains development utilities for these +flows, including debug-mode variants. Those VS Code tasks are for extension +development only and may change or be removed; the commands above are the +release-mode forms to use directly. + +## Current Features + +The in-app help menu is the source of truth for the current feature set. The +main capability today is module-level inspection: + +- Select a block from the Module Tree. +- View that module's live port and internal `Logic` values in the Details pane. +- Search and filter the Module Tree and signal list. +- Refresh the module hierarchy from the connected VM. +- Export the signal details table as a PNG. + +## Contributions + +We welcome contributions to the development of the ROHD DevTools extension. +Please refer to the contributing documentation for guidance on how to get +started. + +## Running Tests + +The ROHD DevTools extension runs in an iframe when embedded in DevTools, so use +the Chrome platform for browser-based widget tests. + +```sh +flutter test --platform chrome test/ +``` diff --git a/rohd_devtools_extension/analysis_options.yaml b/rohd_devtools_extension/analysis_options.yaml index 0d2902135..f82d6cc51 100644 --- a/rohd_devtools_extension/analysis_options.yaml +++ b/rohd_devtools_extension/analysis_options.yaml @@ -1,28 +1,250 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +# Lints set up with some guidance from here: +# https://rydmike.com/blog_flutter_linting.html -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true +# keep up to date, matching https://dart.dev/tools/linter-rules/all +# some lints are not yet available, so disabled and marked with [not currently recognized] linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + - always_declare_return_types + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + # - always_specify_types + - always_use_package_imports + - annotate_overrides + - annotate_redeclares + # - avoid_annotating_with_dynamic + - avoid_bool_literals_in_conditional_expressions + - avoid_catches_without_on_clauses + - avoid_catching_errors + # - avoid_classes_with_only_static_members + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + - avoid_final_parameters + - avoid_function_literals_in_foreach_calls + - avoid_futureor_void + - avoid_implementing_value_types + - avoid_init_to_null + - avoid_js_rounded_ints + - avoid_multiple_declarations_per_line + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_print + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - close_sinks + - collection_methods_unrelated_type + - combinators_ordering + - comment_references + - conditional_uri_does_not_exist + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + - deprecated_member_use_from_same_package + - diagnostic_describe_all_properties + - directives_ordering + - discarded_futures + - do_not_use_environment + # - document_ignores + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + - invalid_runtime_check_with_js_interop_types + - join_return_with_assignment + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + - lines_longer_than_80_chars + - literal_only_boolean_expressions + - matching_super_parameters + - missing_code_block_language_in_doc_comment + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_literal_bool_comparisons + - no_logic_in_create_state + - no_runtimeType_toString + - no_self_assignments + - no_wildcard_variable_uses + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + - omit_local_variable_types + - omit_obvious_local_variable_types + # - omit_obvious_property_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_names + - package_prefixed_library_names + # parameter_assignments - disabled; ROHD idiomatically reassigns + # constructor parameters via addInput/addOutput. + # - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_constructors_over_static_methods + - prefer_contains + # - prefer_double_quotes + - prefer_expression_function_bodies + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + # - prefer_final_parameters + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + - prefer_null_aware_method_calls + - prefer_null_aware_operators + # - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + # - require_trailing_commas + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + # - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + # conflicts with omit_obvious_local_variable_types + # - specify_nonobvious_local_variable_types + # - specify_nonobvious_property_types + - strict_top_level_inference + # - switch_on_type + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - type_literal_in_constant_pattern + - unawaited_futures + - unintended_html_in_doc_comment + # - unnecessary_async + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + # - unnecessary_final + - unnecessary_getters_setters + - unnecessary_ignore + - unnecessary_lambdas + - unnecessary_late + - unnecessary_library_directive + - unnecessary_library_name + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_aware_operator_on_extension_on_nullable + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_raw_strings + # - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + # - unnecessary_unawaited + - unnecessary_underscores + # - unreachable_from_main + - unrelated_type_equality_checks + # - unsafe_variance + - use_build_context_synchronously + - use_colored_box + - use_decorated_box + - use_enums + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_if_null_to_convert_nulls_to_bools + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_null_aware_elements + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + - use_to_and_as_if_applicable + - use_truncating_division + - valid_regexps + - void_checks \ No newline at end of file diff --git a/rohd_devtools_extension/assets/help/details_help.md b/rohd_devtools_extension/assets/help/details_help.md new file mode 100644 index 000000000..27689d8df --- /dev/null +++ b/rohd_devtools_extension/assets/help/details_help.md @@ -0,0 +1,28 @@ +# ℹ️ Module Details — Help + + + +Signal Details + Click module Select module to view signals + Signal list Shows ports and internal signals + +Signal Values + Value column Current signal value (hex/binary) + Width column Bit width of each signal + + + +## Signal Details + +| Action | Description | +| --- | --- | +| Click module (tree) | Select module and populate signal list | +| Signal list | Shows input ports, output ports, and internal signals | +| Value column | Displays the current value of each signal | +| Width column | Shows the bit width of each signal | + +## Export + +| Action | Description | +| --- | --- | +| 📷 Camera | Export signal table as PNG image | diff --git a/rohd_devtools_extension/assets/help/devtools_help.md b/rohd_devtools_extension/assets/help/devtools_help.md new file mode 100644 index 000000000..f7845a6bc --- /dev/null +++ b/rohd_devtools_extension/assets/help/devtools_help.md @@ -0,0 +1,34 @@ +# 🛠 ROHD DevTools — Help + + + +Module Tree (left panel) + Click node Select module + Click ▸ / ▾ Expand / collapse + 🔃 Refresh Reload hierarchy from VM + Type in search Filter modules by name + +Details (right panel) + Signal list Shows ports and internal signals + Search Filter signals by name + Filter Toggle input / output visibility + + + +## Module Tree (left panel) + +| Key | Description | +| --- | --- | +| Click module | Select module and show signals | +| Click ▸ / ▾ | Expand or collapse sub-modules | +| 🔃 Refresh | Reload hierarchy from the VM | +| Type in search | Filter modules by name | + +## Signal Details (right panel) + +| Key | Description | +| --- | --- | +| Signal list | Shows input ports, output ports, and internal signals | +| Search | Filter signals by name | +| Filter icon | Toggle input / output signal visibility | +| 📷 Export | Export signal details as PNG | diff --git a/rohd_devtools_extension/assets/icons/rohd_logo.png b/rohd_devtools_extension/assets/icons/rohd_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a8f1faecdde32ff3c2a4b01f5ca74a58c88c2726 GIT binary patch literal 5225 zcmZ`-cQoAFxBn_bMvXCQ5M}gU61|3)G3qFzchQ6BBu0qdOGGE41ks}=%7`JsB}DW- z*VTiB5cSRdy|><4?~k{><*e^MyPfa(?7h!935NRG)D)}~002-U;F>5x+xPEti;VC_ zEqaj?8d6tvJ#_$RN~64V01>`9o!}@v00`p)fXEjBa84jaZUI1`1OV*V1Asy<05JI$ zb{Q!VI>;Q6+M2-4zgKB@RThCl9)Qr(BHttN-QQ9cnWV7vN3r- zotS9Z4#^f7J@i53`ElB--)Bh*@_SlfdBnkZ%5RT} z1_dmm=FAhn9Yt0ih#)FI`?!uD-x6;46KfND6CfRtKrILLFZd5XrK<>Vs?!J_k-Teg zyG|qXef}`Vq(&6=SspX<-DC^B75hxxhZR2C8c`=2QOdsQETb~Kh$WL{Jwcdxbcqv~ zWBFK*?~5iCby_lw!>>fj9J8=h#~?SbO6sSAo(b3-Ys;aK2rCCm9sX=)o%FJN`;J~_ zon-h#CeMvAl_ln*+n0=8Mp5}m_L(ACJPDHxSBI%?21nFVLC+dYP#URpIdVv}XSRWD zP{z#7wvQYsST7w@^CR+JuVqa9g*(0DN3#rDp08nL%3hBxjyBqxJ5J#TpB7b>q%hUC z^PaITN4?Zr3CZ-miK($TqkMYng5YS4hp#ofk)>oPTvGy-DKk86=HZHWd&+!a7TT;4 zm2xI9Hqce68d<)OJp|zAk{c4;M|Z2A>NaNOM%l!wW;Z=Rkm&LOT-e}4OKbHF$Y}qA z0qFLpMkl|q@^GqzW$f^JFMU&%xxKP3C`)ibC!PgT+wq2b`6jXEN81*Sw@|9ZoL?5U zG8^-g?Wv9~-k_lRp{EK%$$+{kO>&lVxL^uTZxYfq?BhNd9U-Oie2AMDp8qJtvD<^s z%je@Y_Dtj<9GrRWssI*FgE~d=3~+XBhr=`7rI(WHR2dz{v@=E3CwoU>E=TGiz3E(t z^US<|_O3IwD0mTeD6Pq`HUP1LiUEG*_awmb#4Ori+cJV`lkTX<@^9q;q6$Hjsh>p* zE8+ve74#_51M;Dq|CgcKv0%!~7`Dq6Ulrf&urTmwk@u(72o(dyY&krX-Hc(y=Y7T7 z+4J?DX(mit>dx>Cl>&*l8us;B>1=nicVOK8E)09&>$=N7d`*=6M|~@%izBS>7%KSC z_@G58C8@A(0k|>AnJPdfNiBz>6B<3t;emCB7W|~Ew&3Z6roQbiQ7rR=S;}$x zmp47nW$|2}c;2*haca2!hk*jTEGWApAWfTw4fo^FA6$-J<_^#Se(+%94$UOtx@{6j zlM6ZOn^>ntdqb_QLSRuCOJ=lt?A^>e%i3}zX9VnfapC$9UlBwdC8R!mKKVVBInB4l zvFO96qbxA6f-rEJvFh&_Cc~SXOQPlJD;30=EoM{&B6xRy4T*~KZel(_ebO(}n+W|^ zLjx{Q&(1h0FAaZ0M4=TkU@g2JeXdVr+^p82J zdpa>7&$MPqpVBwbEp&>xpGQ~?r!zxWQt0EfA0BKCNhC%*CW<|zpj$y%NL!!tM$k75 zZs@JJC9(83g(wE?4b>Z}QZ}-Da(r;G`KGzzB9n9ll6h6LUEnR*f}<%L+Wy2IeQp7i zjrTN|zYWa@gPmo}e!Xa}^l8J1+^*d*sOV)JIk$sR&SL9k{ks?hSMK^&KS$$lD zp55o)5(3119NeGxUkX{*vGHR^4qGaH-s9L3=}pfdH3jMrfFu4iezbwzBYjn4f?WsP zHkR%dL+g32Q8`=+20p|OyppZaj!M!B7T}##NYe89z8>KQA~OQosD|~82I@@I5m9!D z7HR&zDR9k$lNomI5$}{-DmQuq9HrY$WQRCYf8}T5k08ahDMH3EWf0G~($UTMQJu$z zw&olqjAJ*4jNziP(TMkiBGM0z`eB8f)%CPo>OwwrU6_YY)Xk zvg0=}&zF-VknjF_O4lxP#iAFKVPl_~+kdIJH-C(q`iqcvE&Z^QCyvuQFgD&7=CW@{ zd{ooSyvmoSo7B*xoH=`T+_2g1l}{m68eIKkmnKy6E6*3gLcqT?cfZXm=-NOc(^o6F zhy6<`#VgTJn{q{*#+sCoR1;bW;hfc)Sr3*78XsqB+PesN&HFF-)-Cf~O)|TvsNJIzjzZVFqVIUKi5E+T z5>RV#QnQe3%jXz0=GjzJih`vUMPncb7fR&r)N#ylyd8cxf4s@idA~IgYVXLArs~>g z^*m}pEA>Iq=!CWN&Wmz@UK$2bw3PL&eg0QR`9J=nll^J~Q2QspaU}UY@d|<|B=yFa z_inqke1d&{kJ9_k+N{!?t~7q|2y#E?8zQ7qr+17W{+dDT7Pn8^7)~#=1Dgrq!e*dKDWQ#g;B}F0Gj*fkzO+1gC8CBRs<@Z>tjQ^*YF=de4#D;G z2n>&wMzr?T8Q!xL5+cgl+P8>6xpj!V`ej0*(SU%ff_g8JWmc(^CTrMBzQTnVt3 zhg^scdqmPV!U_JwvqOCr4RDWqG*k>OcM~;{#NZ7+UjmZGia7E9Dj8NF{33cP)vPC zJGuWjlRSoiJka(JUef9`744d{9D^Kx9om|8ob_zowC59MHdeEAR@y`1I&3i>gVQGD zLA0Sw@auGYl83lYH}|^{JT`)Dug{;+j@{#FrkoP_)!3U`^an!odj5wQ*S9dEogduN4q730S@{r&QA+!))Z0&-&_U%h)M z;*qg>8uDtEwK}!>%lD9m<$WMKT?!8J@46;ineqW+|q|O#ScMtAc9TF*C zIOrfYh`RJzl)Q+Ama5Jo_dISWPT+@>A8XDIO#aZ>rk12Cn%QL#AJZn`IDawZ8fTI6 z&;K5rJ6%oUu732V!|9k^Pz}=rhac*U)~ZL{P0X<`+t2)QTT*Mp_pgqUeIzRK+KFyC zPS!qx8gi-j!T__9!+_UY_r7`3^tJT~p~7EWuknMn&_Rsy-Tc+sQ5)P2*HF6RqlZcc zr2D3AAj(+9N(uRgACys@P~;@~HmaLkbDzQ6>lKgMKkV>=c5CEKU-9&6s2y zRh>ah#pv%>m|J6=4e=^~+Anx9CU6KN?P|4Rznk=$F{lQ+Zg1`m59jm1&Vb)=Fdx6` zb3Rudl{5^LcHf+4Cu-fZ=H1}@2T!2`&$B;!Be7y@n@sM}~gZ+=EqtccxmO?-A>biFVZOMCUknJ$xZki2inx zhB!3ZQzg`s22hU&4=A@V#l!NJ5N>i2276l@^)r2zY}<+ZyA-cL2C=UG_h-KnIxGW> zBP{UpskcK1Y591Wc|dNtsNgMb$+X_G!Efx(4e)9xVpOD=gc$S>?1aX=gjL^ic~|GKJ$R17_3%tfHW$T2P1=WS0gL{lPi+{ofjEc-Lf;Xy<&KJI3 zc5F*y6jk5Q3cZMdmc~FK@8}EWo>_O_v1St(B;^S(An2$dQ2XtY!U_WUD%k`c#if>7 zLCOKqN|Vvw4Tp942C`;p?~MtRD=f9+>%F0SC9dYl%Bd&^!NL1k#P19J0+sCehv}xj zTyqD#ohNCvnc;`b)(+Ha; zQR2;>(rc+$rxW-}d`vVafqN4QS>)4ryE`l@){^B0UVaR~-~`Cs!4-7YFRn}d(n7yI z;3ZHZCy%#YI?fDrp_AsgvF`cR8fe7E4%A*v0O8w|zFNak7^Gq=+~&R0O1gV=k;ef8 zF>|(MS}i7})VktBn#>wKX?e}X`*E(C6cYB?u(Mcm(VlMdsv;hk9A)_Lx4_I&neSXO z!B9GxSyDpIDl?sCyK51D*Y8Ugr_-M~3X zIrj7A0Eupzf6`EH%$t~yiZ&rMA(S1QR za)AxTUW=tr;83uXN8endzMDwFM zNo#Q`Up8m;33yC3%3M_}TmE_w7IHq6m{Upvj^K|X8gFJdeq(w|VkQzz!z`l%DHPTy zaAh-YVOP?DA36)YS~dH~z>k+T{?jQ$I*S~LKh6I$Tlf4I4QYkWR0*S1jlSn?c|DF8 zgEe2FSN3(E)5Bj1j?z#Qb|hl~wr4bSkPD|HzPah6*cTH1{AcYCxK{$dlVW-;28!8I zgA+`;yUxI=1N+fkIJcuiQ7Bze105H2_qvD3r}pZ{cV>7E6vOuxs^Y~a)akDlgh=g2 zNIpzYk>>KNY$aUpKRSAtzjfg1WaL+4r3acbDot#1H8K0dtIQUaTl=K5SIa*pV z=$AnBa37__L$3aavlnG*k7+v*CWUfnWe8hx+A0`-qc=v(>vC=IG3DmN(B_gA>DO+x z7?Yi(hbDmtkw?{$E?IB3CWlTK`+b}@9Dn5)UA=Vw! zkTgFa4gy5xUMCG+a}v_hAIHIJ>Ev3~^Pt5!Jd(LqBlsgl z?bnoDnr_qtA-KO>3AkiGvsFs=41Wqv(n21FogfZ9E;&K)HXSakGWTRN`iqPU2j_IC zzF``v>QIwBwSxSsB;Vyb4$Q0!UspQXJ5?Xa=LN!ap-|=25gK^mQKG z_x(|%^>K1x$}mO@1m|-hRF$Ew2QvvjKK~>WbLxTr9a@`62(`@pHIxy!o~eRqs3qFG zT#@>N39mXux^4n$<)SJ?K4PlRWt531ys8!+1?9|>;sxj6E4aC?iaG2C*_v#=Z6~6V zrRlf`W4#x4L;3h#g(UX7quAA7Ww(9AUFkRDaqn728!47VBs&moV_MuRTrApLSe>KW zDptCtww1CBTk+{p z>;pYayq7(7~!CdH$clgz}X?dMZxKb3!wp|B&4K8CGLw#NSR1VDBP1$kdYLY zkWi42*tqp9=KlzI`#8Hl3jO~DvQ2UF1OarIiFp9ZA(+$miO(Z|D?L*XC@#pY%ns4*gyd!Jd|Qil=M9G$Sf!cJ*Q@*%qM3Oo)#0%k=d!w1ET|M h1LPx-@KPNyV26n=;HSpDmjn|4LQ7w>Uc)}>zX0c)l=J`q literal 0 HcmV?d00001 diff --git a/rohd_devtools_extension/lib/main.dart b/rohd_devtools_extension/lib/main.dart index 34fbedef9..278c04212 100644 --- a/rohd_devtools_extension/lib/main.dart +++ b/rohd_devtools_extension/lib/main.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // main.dart @@ -7,26 +7,122 @@ // 2025 January 28 // Author: Roberto Torres +import 'dart:js_interop'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_extensions/api.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; import 'package:rohd_devtools_extension/rohd_devtools/view/rohd_devtools_page.dart'; - import 'package:rohd_devtools_extension/rohd_devtools_observer.dart'; +import 'package:web/web.dart' as web; void main() { + debugPrint('[main.dart] Starting ROHD DevTools Extension...'); + debugPrint('[main.dart] Platform: ${kIsWeb ? "Web" : "Native"}'); + + // Policy: preserve extension UI state across target-app restarts. + // DevTools emits forceReload when the debugged app restarts, but a full page + // reload would discard local selection and snapshot state that the extension + // can now recover through its own reconnect path. Intercept the wrapper event + // before DevToolsExtension handles it so reconnect is graceful instead of a + // hard reload. + _installMessageInterceptor(); + /// Initializing the [BlocObserver] created and calling runApp + debugPrint('[main.dart] Initializing BlocObserver...'); Bloc.observer = const RohdDevToolsObserver(); + debugPrint('[main.dart] Calling runApp...'); runApp(const RohdDevToolsApp()); + debugPrint('[main.dart] runApp called successfully'); +} + +/// Intercepts DevTools wrapper messages before the ExtensionManager +/// can process them. +/// +/// Policy note: this extension intentionally owns restart recovery instead of +/// delegating to the default DevTools page reload path. +/// +/// Blocks: +/// - `forceReload` – prevents the full page reload that would destroy local +/// extension state. Instead, the extension disconnects the stale VM service +/// and requests a fresh VM URI from DevTools so state can survive through a +/// controlled reconnect. +void _installMessageInterceptor() { + web.window.addEventListener( + 'message', + ((web.MessageEvent e) { + try { + final data = e.data.dartify(); + if (data is! Map) { + return; + } + final type = data['type']; + + final source = data['source'] ?? '?'; + debugPrint('[ROHD-MSG] type=$type source=$source ' + 'data=${data['data']}'); + + if (type == 'forceReload') { + debugPrint('[ROHD-MSG] BLOCKED forceReload — ' + 'triggering graceful reconnection'); + e.stopImmediatePropagation(); + + // After blocking the page reload, disconnect the stale VM + // service and ask DevTools for the current (restarted) URI. + // A short delay lets the DevTools wrapper finish its own + // transition before we re-request. + Future.delayed(const Duration(milliseconds: 300), () async { + try { + if (serviceManager.connectedState.value.connected) { + debugPrint('[ROHD-MSG] Disconnecting stale VM...'); + await serviceManager.manuallyDisconnect(); + } + debugPrint('[ROHD-MSG] Requesting fresh VM URI ' + 'from DevTools...'); + extensionManager.postMessageToDevTools(DevToolsExtensionEvent( + DevToolsExtensionEventType.vmServiceConnection)); + } on Object catch (err) { + debugPrint('[ROHD-MSG] Reconnection request ' + 'failed: $err'); + } + }); + return; + } + } on Object catch (_) {} + }).toJS); } +/// The main ROHD DevTools application. class RohdDevToolsApp extends StatelessWidget { + /// Creates the main ROHD DevTools application. const RohdDevToolsApp({super.key}); + @override Widget build(BuildContext context) { - return const DevToolsExtension( - child: RohdDevToolsPage(), - ); + debugPrint('[RohdDevToolsApp] Building app widget...'); + return DevToolsExtension( + // Reset IdeTheme scaling so extension renders at 1× size + // regardless of the IDE's editor.fontSize setting. + child: Builder(builder: (context) { + final current = ideTheme; + setGlobal( + IdeTheme, + IdeTheme( + backgroundColor: current.backgroundColor, + foregroundColor: current.foregroundColor, + embedMode: current.embedMode, + isDarkMode: current.isDarkMode)); + + final isDark = Theme.of(context).brightness == Brightness.dark; + final base = isDark ? buildDarkTheme() : buildLightTheme(); + + return Theme(data: base, child: const RohdDevToolsPage()); + })); } } diff --git a/rohd_devtools_extension/lib/main_standalone.dart b/rohd_devtools_extension/lib/main_standalone.dart new file mode 100644 index 000000000..f42e8c4f6 --- /dev/null +++ b/rohd_devtools_extension/lib/main_standalone.dart @@ -0,0 +1,52 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// main_standalone.dart +// Unified standalone entry point for both web and native (Linux/macOS/ +// Windows) builds. The platform-appropriate [VmConnectionStrategy] is +// selected via conditional imports in +// `rohd_devtools/services/platform_vm_connection_strategy.dart`. +// +// Run on web: flutter run -d web-server lib/main_standalone.dart +// Run on Linux: flutter run -d linux lib/main_standalone.dart +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/standalone_app_shell.dart'; + +/// Entry point for the standalone ROHD DevTools app. +void main(List args) { + _setupLogging(); + + final config = StandaloneAppConfig( + title: 'ROHD DevTools', + connectionStrategy: createPlatformVmConnectionStrategy(), + ); + + debugPrint( + '[main_standalone] Starting ROHD DevTools ' + '(${kIsWeb ? "Web" : "Native"})...', + ); + runApp(StandaloneRohdDevToolsApp(config: config)); +} + +void _setupLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + final ts = record.time.toIso8601String(); + debugPrint( + '[$ts] [${record.loggerName}] ${record.level.name}: ${record.message}', + ); + if (record.error != null) { + debugPrint(' error: ${record.error}'); + } + if (record.stackTrace != null) { + debugPrint(' stack: ${record.stackTrace}'); + } + }); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart b/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart new file mode 100644 index 000000000..2c6df9e89 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart @@ -0,0 +1,193 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_theme.dart +// Centralized theme definitions for ROHD DevTools. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +const _fontFallback = ['Noto Color Emoji']; + +TextTheme _withFontFallback(TextTheme theme) => theme.apply( + fontFamilyFallback: _fontFallback, + ); + +/// Dark theme colors +class DarkThemeColors { + /// Colors matching VS Code dark theme. + static const scaffoldBackground = Color(0xFF1E1E1E); + + /// Card background color. + static const cardBackground = Color(0xFF252526); + + /// Panel background color. + static const panelBackground = Color(0xFF252526); + + /// Panel header color. + static const panelHeader = Color(0xFF333333); + + /// Divider color. + static const divider = Color(0xFF3C3C3C); + + /// Primary text color. + static const text = Colors.white; + + /// Secondary text color. + static const textSecondary = Colors.white70; + + /// AppBar background color. + static const appBarBackground = Color(0xFF252526); +} + +/// Light theme colors +class LightThemeColors { + /// Slightly darker than white + /// to reduce eye strain. + static const scaffoldBackground = Color(0xFFE8E8E8); + + /// Card background color. + static const cardBackground = Colors.white; + + /// Panel background color. + static const panelBackground = Color(0xFFFAFAFA); + + /// Panel header color. + static const panelHeader = Color(0xFFF5F5F5); + + /// Divider color. + static const divider = Colors.black26; + + /// Primary text color. + static const text = Colors.black87; + + /// Secondary text color. + static const textSecondary = Colors.black54; + + /// AppBar background color. + static const appBarBackground = Color(0xFFF5F5F5); +} + +/// AppBar themes +class AppBarThemes { + /// Dark theme AppBar - matches VS Code dark theme + static const dark = AppBarTheme( + backgroundColor: DarkThemeColors.appBarBackground, + foregroundColor: DarkThemeColors.text, + elevation: 0, + shadowColor: Colors.transparent, + ); + + /// Light theme AppBar + static const light = AppBarTheme( + backgroundColor: LightThemeColors.appBarBackground, + foregroundColor: LightThemeColors.text, + elevation: 0, + shadowColor: Colors.transparent, + ); +} + +/// Build dark theme data +ThemeData buildDarkTheme() => ThemeData.dark().copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF4A90A4), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: DarkThemeColors.scaffoldBackground, + cardColor: DarkThemeColors.cardBackground, + dividerColor: DarkThemeColors.divider, + cardTheme: const CardThemeData( + elevation: 0, + shadowColor: Colors.transparent, + color: DarkThemeColors.cardBackground, + ), + appBarTheme: AppBarThemes.dark, + popupMenuTheme: PopupMenuThemeData( + color: const Color(0xFF3C3C3C), + elevation: 8, + shadowColor: Colors.black54, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + textStyle: const TextStyle(color: Colors.white, fontSize: 13), + ), + dialogTheme: DialogThemeData( + backgroundColor: const Color(0xFF2D2D30).withValues(alpha: 0.90), + elevation: 16, + shadowColor: Colors.black54, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle(color: Colors.white70, fontSize: 14), + ), + // Disable hover effects (workaround for Flutter #172079) + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + textTheme: _withFontFallback(ThemeData.dark().textTheme), + primaryTextTheme: _withFontFallback(ThemeData.dark().primaryTextTheme), + ); + +/// Build light theme data +ThemeData buildLightTheme() => ThemeData.light().copyWith( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4A90A4)), + scaffoldBackgroundColor: LightThemeColors.scaffoldBackground, + cardColor: LightThemeColors.cardBackground, + dividerColor: LightThemeColors.divider, + cardTheme: CardThemeData( + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + color: LightThemeColors.cardBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.black.withValues(alpha: 0.1)), + ), + ), + appBarTheme: AppBarThemes.light, + popupMenuTheme: PopupMenuThemeData( + color: Colors.white.withValues(alpha: 0.85), + elevation: 8, + shadowColor: Colors.black26, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.black.withValues(alpha: 0.12)), + ), + textStyle: const TextStyle(color: Colors.black87, fontSize: 13), + ), + dialogTheme: DialogThemeData( + backgroundColor: Colors.white, + elevation: 16, + shadowColor: Colors.black26, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.black.withValues(alpha: 0.1)), + ), + titleTextStyle: const TextStyle( + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle(color: Colors.black54, fontSize: 14), + ), + // Disable hover effects (workaround for Flutter #172079) + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + textTheme: _withFontFallback(ThemeData.light().textTheme), + primaryTextTheme: _withFontFallback(ThemeData.light().primaryTextTheme), + ); diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart new file mode 100644 index 000000000..18c07803d --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart @@ -0,0 +1,13 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cubits.dart +// Barrel file for rohd_devtools cubits. + +export 'details_tab_cubit.dart'; +export 'rohd_service_cubit.dart'; +export 'selected_module_cubit.dart'; +export 'signal_search_term_cubit.dart'; +export 'snapshot_cubit.dart'; +export 'theme_cubit.dart'; +export 'tree_search_term_cubit.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart new file mode 100644 index 000000000..8ab1cabb0 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart @@ -0,0 +1,31 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// details_tab_cubit.dart +// Cubit for managing the selected tab in module details view. +// +// 2025 January 12 +// Author: Desmond Kirkpatrick + +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Enum representing the available tabs in the module details view. +enum DetailsTab { + /// Details tab showing module information. + details, + + /// Waveform tab showing signal waveforms. + waveform, + + /// Schematic tab showing module schematics. + schematic, +} + +/// Cubit for managing the selected tab state. +class DetailsTabCubit extends Cubit { + /// Initializes the cubit with the default tab as [DetailsTab.details]. + DetailsTabCubit() : super(DetailsTab.details); + + /// Sets the currently selected tab. + void selectTab(DetailsTab tab) => emit(tab); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart index 2b8b70b79..6850dcd7b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_service_cubit.dart @@ -7,28 +7,137 @@ // 2025 January 28 // Author: Roberto Torres +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; -import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:devtools_app_shared/utils.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/service_manager_bridge.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source_binding.dart'; import 'package:rohd_devtools_extension/rohd_devtools/services/tree_service.dart'; +import 'package:vm_service/vm_service.dart' as vm; part 'rohd_service_state.dart'; +/// Cubit for managing ROHD service state. class RohdServiceCubit extends Cubit { + final bool _manageServiceManager; + ServiceManager? _localServiceManager; + + /// Completer used to signal teardown of the standalone VM service to the + /// local [ServiceManager]. + Completer? _localServiceClosedSignal; + + /// The TreeService instance for ROHD. TreeService? treeService; - RohdServiceCubit() : super(RohdServiceInitial()) { - evalModuleTree(); + /// Shared value source for live signal overlays, when available. + SignalValueSource? get signalValueSource => _signalValueSource; + SignalValueSource? _signalValueSource; + + /// The discovered ROHD isolate ID. + /// + /// Exposed so other consumers (e.g. waveform data source) can target the + /// same isolate that contains the ROHD inspector_service library. + String? get rohdIsolateId => _rohdIsolateId; + String? _rohdIsolateId; + + /// Listener for service connection state changes. + void Function()? _connectionListener; + + /// Constructor for RohdServiceCubit. + RohdServiceCubit({bool manageServiceManager = true}) + : _manageServiceManager = manageServiceManager, + super(RohdServiceInitial()) { + if (_manageServiceManager) { + _connectionListener = _onConnectionStateChanged; + serviceManager.connectedState.addListener(_connectionListener!); + if (serviceManager.connectedState.value.connected) { + unawaited(Future.microtask(evalModuleTree)); + } + } + } + + /// Configure a standalone VM service session without relying on the global + /// DevTools extension [serviceManager]. + Future configureStandaloneVmService( + vm.VmService vmService, String isolateId) async { + _rohdIsolateId = null; + _disposeSignalValueSource(); + + if (_localServiceManager != null && + _localServiceClosedSignal != null && + !_localServiceClosedSignal!.isCompleted) { + _localServiceClosedSignal!.complete(); + } + + _localServiceManager = ServiceManager(); + final localManager = _localServiceManager!; + _localServiceClosedSignal = Completer(); + + await localManager.vmServiceOpened(vmService, + onClosed: _localServiceClosedSignal!.future); + + final vmInfo = await vmService.getVM(); + final isolates = vmInfo.isolates ?? const []; + for (final ref in isolates) { + if (ref.id == isolateId) { + localManager.isolateManager.selectIsolate(ref); + break; + } + } + + treeService = null; + _rohdIsolateId = null; } + void _onConnectionStateChanged() { + final connected = serviceManager.connectedState.value.connected; + debugPrint('[RohdServiceCubit] Connection state changed: ' + 'connected=$connected'); + if (connected) { + // Reset tree service so we use the new connection + treeService = null; + unawaited(evalModuleTree()); + } else { + // VM disconnected — reset so tree page can tear down waveforms + // and other stale references. + treeService = null; + _disposeSignalValueSource(); + _rohdIsolateId = null; + emit(RohdServiceInitial()); + } + } + + @override + Future close() { + if (_connectionListener != null && _manageServiceManager) { + serviceManager.connectedState.removeListener(_connectionListener!); + _connectionListener = null; + } + if (_localServiceClosedSignal != null && + !_localServiceClosedSignal!.isCompleted) { + _localServiceClosedSignal!.complete(); + } + _disposeSignalValueSource(); + _localServiceManager = null; + return super.close(); + } + + /// Evaluate the module tree from the ROHD service. Future evalModuleTree() async { + debugPrint('[RohdServiceCubit] evalModuleTree called'); await _handleModuleTreeOperation( (treeService) => treeService.evalModuleTree()); } + /// Refresh the module tree from the ROHD service. Future refreshModuleTree() async { + debugPrint('[RohdServiceCubit] refreshModuleTree called'); await _handleModuleTreeOperation( (treeService) => treeService.refreshModuleTree()); } @@ -36,22 +145,102 @@ class RohdServiceCubit extends Cubit { Future _handleModuleTreeOperation( Future Function(TreeService) operation) async { try { + debugPrint( + '[RohdServiceCubit] _handleModuleTreeOperation - emitting loading'); emit(RohdServiceLoading()); - if (serviceManager.service == null) { - throw Exception('ServiceManager is not initialized'); + + final activeServiceManager = + _manageServiceManager ? serviceManager : _localServiceManager; + final activeService = activeServiceManager?.service; + + if (activeService == null) { + debugPrint('[RohdServiceCubit] ServiceManager is not initialized - ' + 'emitting loaded with null'); + // When not running in DevTools, just emit loaded with null tree + // This prevents constant error states and allows the UI to work + emit(const RohdServiceLoaded(null)); + return; + } + + debugPrint('[RohdServiceCubit] Creating TreeService...'); + if (treeService == null) { + // Find the isolate that actually has the ROHD library loaded. + // With `dart test`, the DevTools "selected" isolate is often the + // test-runner controller which doesn't import package:rohd. We + // need to scan all isolates to find the one with inspector_service. + final service = activeService; + ValueListenable? rohdIsolate; + + try { + final vmInfo = await service.getVM(); + final isolates = vmInfo.isolates ?? []; + debugPrint('[RohdServiceCubit] Scanning ${isolates.length} ' + 'isolate(s) for ROHD library...'); + + for (final isoRef in isolates) { + final id = isoRef.id; + if (id == null) { + continue; + } + try { + final iso = await service.getIsolate(id); + final libs = iso.libraries ?? []; + debugPrint('[RohdServiceCubit] Isolate ${isoRef.name} ' + '(${isoRef.id}): ${libs.length} libraries'); + final hasRohd = libs.any((lib) => + lib.uri == + 'package:rohd/src/diagnostics/inspector_service.dart'); + if (hasRohd) { + debugPrint('[RohdServiceCubit] → Found ROHD in ' + '${isoRef.name}'); + rohdIsolate = ValueNotifier(isoRef); + _rohdIsolateId = id; + break; + } + } on Exception catch (e) { + debugPrint('[RohdServiceCubit] Isolate ${isoRef.name} ' + 'scan error: $e'); + } + } + } on Exception catch (e) { + debugPrint('[RohdServiceCubit] VM scan failed: $e'); + } + + if (rohdIsolate == null) { + debugPrint('[RohdServiceCubit] ROHD isolate not found, ' + 'falling back to selected isolate'); + } + + treeService = TreeService( + EvalOnDartLibrary( + 'package:rohd/src/diagnostics/inspector_service.dart', service, + serviceManager: activeServiceManager!, isolate: rohdIsolate), + Disposable(), + vmService: service, + isolateId: _rohdIsolateId); + + _disposeSignalValueSource(); + _signalValueSource = createSignalValueSourceBinding( + treeService: treeService!, vmService: service); } - treeService ??= TreeService( - EvalOnDartLibrary( - 'package:rohd/src/diagnostics/inspector_service.dart', - serviceManager.service!, - serviceManager: serviceManager, - ), - Disposable(), - ); + + debugPrint('[RohdServiceCubit] Calling operation...'); final treeModel = await operation(treeService!); + + debugPrint('[RohdServiceCubit] Operation complete, emitting loaded'); emit(RohdServiceLoaded(treeModel)); - } catch (error, trace) { + } on Exception catch (error, trace) { + debugPrint('[RohdServiceCubit] Error: $error'); + // Reset treeService so next attempt re-scans for the ROHD isolate. + treeService = null; + _disposeSignalValueSource(); + _rohdIsolateId = null; emit(RohdServiceError(error.toString(), trace)); } } + + void _disposeSignalValueSource() { + unawaited(disposeSignalValueSourceBinding(_signalValueSource)); + _signalValueSource = null; + } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart index c6239e7c9..f31255819 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_service_state.dart @@ -9,30 +9,42 @@ part of 'rohd_service_cubit.dart'; +/// Base state for ROHD service loading and error handling. abstract class RohdServiceState extends Equatable { + /// Creates a ROHD service state. const RohdServiceState(); @override List get props => []; } +/// Initial state before any ROHD service activity occurs. class RohdServiceInitial extends RohdServiceState {} +/// State emitted while loading ROHD service data. class RohdServiceLoading extends RohdServiceState {} +/// State emitted after ROHD service data has been loaded. class RohdServiceLoaded extends RohdServiceState { + /// Loaded module tree data, if available. final TreeModel? treeModel; + /// Creates a loaded state with tree data. const RohdServiceLoaded(this.treeModel); @override List get props => [treeModel]; } +/// State emitted when ROHD service loading fails. class RohdServiceError extends RohdServiceState { + /// Error message. final String error; + + /// Stack trace associated with the failure. final StackTrace trace; + /// Creates an error state. const RohdServiceError(this.error, this.trace); @override diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart index 500d0661f..8df1df7f9 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // selected_module_cubit.dart @@ -7,15 +7,18 @@ // 2025 January 28 // Author: Roberto Torres -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; part 'selected_module_state.dart'; +/// Cubit that tracks which module is currently selected. class SelectedModuleCubit extends Cubit { + /// Creates the selected-module cubit. SelectedModuleCubit() : super(SelectedModuleInitial()); + /// Selects a module and emits the loaded state. void setModule(TreeModel module) { emit(SelectedModuleLoaded(module)); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart index 513c94758..8201a609d 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // selected_module_state.dart @@ -9,18 +9,24 @@ part of 'selected_module_cubit.dart'; +/// Base state for the currently selected module. abstract class SelectedModuleState extends Equatable { + /// Creates a selected-module state. const SelectedModuleState(); @override List get props => []; } +/// State emitted when no module is selected. class SelectedModuleInitial extends SelectedModuleState {} +/// State emitted when a module has been selected. class SelectedModuleLoaded extends SelectedModuleState { + /// The currently selected module. final TreeModel module; + /// Creates a loaded state with the selected module. const SelectedModuleLoaded(this.module); @override diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart index 15a8edbb7..af3cbdc7f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // signal_search_term_cubit.dart @@ -9,9 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +/// Cubit that stores the current signal-table search term. class SignalSearchTermCubit extends Cubit { + /// Creates the signal-search cubit with no initial term. SignalSearchTermCubit() : super(null); + /// Updates the search term. void setTerm(String term) { emit(term); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/snapshot_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/snapshot_cubit.dart new file mode 100644 index 000000000..88c77d6c8 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/snapshot_cubit.dart @@ -0,0 +1,233 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// snapshot_cubit.dart +// Cubit for managing signal value snapshots at a point in time. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source.dart'; + +/// Operating mode for the snapshot system. +enum SignalTrackingMode { + /// Camera mode — user manually takes a one-shot snapshot at the marker + /// time by pressing the snapshot button. + camera, + + /// Video mode — automatically tracks the latest signal values from the + /// ROHD debugger. Each time a value update arrives, a snapshot is taken. + video, +} + +/// Represents a single signal's value at the snapshot time. +class SignalSnapshot extends Equatable { + /// The signal ID or full path. + final String signalId; + + /// The display name of the signal. + final String name; + + /// The signal value at the snapshot time. + final String value; + + /// The bit width of the signal. + final int width; + + /// The direction of the signal (input/output/inout), null if internal. + final String? direction; + + /// Whether this value was computed extension-side. + final bool computed; + + /// Creates a [SignalSnapshot]. + const SignalSnapshot({ + required this.signalId, + required this.name, + required this.value, + required this.width, + this.direction, + this.computed = false, + }); + + @override + List get props => [ + signalId, + name, + value, + width, + direction, + computed, + ]; +} + +/// State for the [SnapshotCubit]. +sealed class SnapshotState extends Equatable { + /// Creates a [SnapshotState]. + const SnapshotState(); +} + +/// No snapshot has been taken yet. +class SnapshotInitial extends SnapshotState { + /// Creates a [SnapshotInitial]. + const SnapshotInitial(); + + @override + List get props => []; +} + +/// A snapshot is currently being fetched. +class SnapshotLoading extends SnapshotState { + /// The time being queried. + final int time; + + /// Creates a [SnapshotLoading]. + const SnapshotLoading(this.time); + + @override + List get props => [time]; +} + +/// A snapshot has been successfully fetched. +class SnapshotLoaded extends SnapshotState { + /// The time at which the snapshot was taken, in source-provided units. + final int time; + + /// Map of signal ID to [SignalSnapshot]. + final Map signals; + + /// Creates a [SnapshotLoaded]. + const SnapshotLoaded({required this.time, required this.signals}); + + @override + List get props => [time, signals]; + + /// Look up a signal's snapshot value by signal ID. + SignalSnapshot? getSignal(String signalId) => signals[signalId]; + + /// Look up a signal's snapshot value by signal name. + SignalSnapshot? getSignalByName(String name) { + for (final snapshot in signals.values) { + if (snapshot.name == name) { + return snapshot; + } + } + return null; + } +} + +/// An error occurred while fetching the snapshot. +class SnapshotError extends SnapshotState { + /// The error message. + final String message; + + /// Creates a [SnapshotError]. + const SnapshotError(this.message); + + @override + List get props => [message]; +} + +/// Cubit that manages signal value snapshots. +class SnapshotCubit extends Cubit { + /// Creates a snapshot cubit. + SnapshotCubit() : super(const SnapshotInitial()); + + /// The current operating mode. + SignalTrackingMode _mode = SignalTrackingMode.camera; + + /// The current operating mode. + SignalTrackingMode get mode => _mode; + + StreamSubscription? _liveUpdatesSub; + + /// Switch between camera and video mode. + void setMode(SignalTrackingMode mode) { + if (_mode == mode) { + return; + } + _mode = mode; + if (mode == SignalTrackingMode.camera) { + _stopVideoTracking(); + } + debugPrint('[SnapshotCubit] Mode changed to ${mode.name}'); + } + + /// Start video-mode tracking from a shared value source. + void startVideoTracking(SignalValueSource source) { + _stopVideoTracking(); + final liveUpdates = source.updates; + if (liveUpdates == null) { + debugPrint('[SnapshotCubit] Video tracking unavailable for source'); + return; + } + _liveUpdatesSub = liveUpdates.listen( + (event) { + if (_mode == SignalTrackingMode.video && + event.upToTime > 0 && + event.hasData) { + unawaited(takeSnapshot(source, event.upToTime)); + } + }, + onError: (Object e) { + debugPrint('[SnapshotCubit] Video tracking error: $e'); + }, + ); + } + + void _stopVideoTracking() { + unawaited(_liveUpdatesSub?.cancel()); + _liveUpdatesSub = null; + } + + /// Take a snapshot of all signal values at the given [time]. + Future takeSnapshot(SignalValueSource source, int time) async { + emit(SnapshotLoading(time)); + + try { + final rawData = await source.getSnapshot(time); + + if (rawData == null) { + emit(const SnapshotError('No snapshot data returned')); + return; + } + + final signals = {}; + for (final entry in rawData.entries) { + final signalId = entry.key; + final data = entry.value; + signals[signalId] = SignalSnapshot( + signalId: signalId, + name: (data['name'] as String?) ?? signalId, + value: (data['value'] as String?) ?? '?', + width: (data['width'] as int?) ?? 1, + direction: data['direction'] as String?, + computed: data['computed'] as bool? ?? false, + ); + } + + emit(SnapshotLoaded(time: time, signals: signals)); + } on Object catch (e) { + debugPrint('[SnapshotCubit] Error taking snapshot: $e'); + emit(SnapshotError('Failed to take snapshot: $e')); + } + } + + /// Clear the current snapshot, reset mode, and stop video tracking. + void clear() { + _stopVideoTracking(); + _mode = SignalTrackingMode.camera; + emit(const SnapshotInitial()); + } + + @override + Future close() { + _stopVideoTracking(); + return super.close(); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart new file mode 100644 index 000000000..34fdd868f --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// theme_cubit.dart +// Manages light/dark theme toggle for ROHD DevTools. + +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Enum for theme modes. +enum DevToolsThemeMode { + /// Light theme mode. + light, + + /// Dark theme mode. + dark, +} + +/// Cubit for managing DevTools theme state. +class DevToolsThemeCubit extends Cubit { + /// Constructor for [DevToolsThemeCubit]. + DevToolsThemeCubit() : super(DevToolsThemeMode.dark); + + /// Toggle between light and dark themes. + void toggleTheme() { + emit( + state == DevToolsThemeMode.dark + ? DevToolsThemeMode.light + : DevToolsThemeMode.dark, + ); + } + + /// Set a specific theme mode. + void setTheme(DevToolsThemeMode mode) { + emit(mode); + } + + /// Whether the current theme is dark. + bool get isDark => state == DevToolsThemeMode.dark; +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart index 0ce2c1933..133be9569 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree_search_term_cubit.dart @@ -9,9 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +/// Cubit that stores the current tree search term. class TreeSearchTermCubit extends Cubit { + /// Creates the tree-search cubit with no initial term. TreeSearchTermCubit() : super(null); + /// Updates the search term. void setTerm(String term) { emit(term); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart b/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart new file mode 100644 index 000000000..2fbbdf241 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// dtd_vm_service_info.dart +// Thin wrapper around the SDK's VmServiceInfo that adds UI state +// (autoReconnect, isAlive) for use in the connection form and +// auto-reconnect logic. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:dtd/dtd.dart'; + +/// Information about a discovered VM service, wrapping the SDK's +/// VmServiceInfo with additional UI-specific mutable state. +/// +/// Used by the VM connection form to display the list of available VMs +/// and by the auto-reconnect logic to match VMs by name. +class DtdVmServiceInfo { + /// The underlying SDK VM service info. + final VmServiceInfo info; + + /// Whether this VM service is currently reachable. + /// + /// Set to `false` by auto-rediscovery when the service is no longer + /// found via the DTD. Dead services are shown grayed-out in the list. + bool isAlive; + + /// Whether to automatically reconnect to this VM by name if it dies + /// and a new VM with the same name appears via DTD discovery. + bool autoReconnect; + + /// Creates a [DtdVmServiceInfo] wrapping the given [info]. + DtdVmServiceInfo({ + required this.info, + this.isAlive = true, + this.autoReconnect = false, + }); + + /// Creates a [DtdVmServiceInfo] from individual fields (convenience). + factory DtdVmServiceInfo.fromFields({ + required String uri, + String? name, + String? exposedUri, + bool isAlive = true, + bool autoReconnect = false, + }) => + DtdVmServiceInfo( + info: VmServiceInfo(uri: uri, exposedUri: exposedUri, name: name), + isAlive: isAlive, + autoReconnect: autoReconnect, + ); + + /// Human-readable name (may be null). + String? get name => info.name; + + /// Direct VM service URI. + String get uri => info.uri; + + /// Exposed/forwarded URI (preferred over [uri] when available). + String? get exposedUri => info.exposedUri; + + /// The URI to use for connection (prefers exposedUri). + String get connectionUri => exposedUri ?? uri; + + /// A compact display label. + String get displayLabel { + final label = name ?? 'VM Service'; + final uriLabel = connectionUri.length > 50 + ? '${connectionUri.substring(0, 50)}…' + : connectionUri; + return '$label — $uriLabel'; + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart index 3cfa0023f..a028d6068 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart @@ -1,40 +1,41 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // signal_model.dart -// Model of the signal to be tabulate on the detail table. +// Model of the signal shown in the details table. // // 2024 January 5 // Author: Yao Jing Quek +/// Model of a signal shown in the details table. class SignalModel { + /// Signal name. final String name; + + /// Signal direction label. final String direction; + + /// Signal value rendered as text. final String value; + + /// Signal bit width. final int width; - SignalModel({ - required this.name, - required this.direction, - required this.value, - required this.width, - }); + /// Creates a signal model. + SignalModel( + {required this.name, + required this.direction, + required this.value, + required this.width}); - factory SignalModel.fromMap(Map map) { - return SignalModel( + /// Builds a signal model from a map representation. + factory SignalModel.fromMap(Map map) => SignalModel( name: map['name'] as String, direction: map['direction'] as String, value: map['value'] as String, - width: map['width'] as int, - ); - } + width: map['width'] as int); - Map toMap() { - return { - 'name': name, - 'direction': direction, - 'value': value, - 'width': width, - }; - } + /// Converts the signal model to a JSON-compatible map. + Map toMap() => + {'name': name, 'direction': direction, 'value': value, 'width': width}; } diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart index f6f60553b..4ef6820c4 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree_model.dart @@ -9,51 +9,83 @@ import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +/// Hierarchical model of a ROHD module tree. class TreeModel { + /// Module name. final String name; + + /// Input signals for the module. final List inputs; + + /// Output signals for the module. final List outputs; + + /// Inout signals for the module. + final List inouts; + + /// Child submodules contained by this module. final List subModules; - TreeModel({ - required this.name, - required this.inputs, - required this.outputs, - required this.subModules, - }); + /// Creates a tree model for a module hierarchy node. + TreeModel( + {required this.name, + required this.inputs, + required this.outputs, + required this.subModules, + this.inouts = const []}); + /// Builds a tree model from a JSON map. factory TreeModel.fromJson(Map json) { - List inputSignalsList = []; - List outputSignalsList = []; + final inputSignalsList = []; + final outputSignalsList = []; + final inoutSignalsList = []; + final inputsJson = json['inputs'] as Map; + final outputsJson = json['outputs'] as Map; + final inoutsJson = json['inouts'] as Map? ?? {}; - for (var inputSignal in json['inputs'].entries) { - SignalModel signal = SignalModel.fromMap({ + for (final inputSignal in inputsJson.entries) { + final inputValue = inputSignal.value as Map; + final signal = SignalModel.fromMap({ 'name': inputSignal.key, 'direction': 'Input', - 'value': inputSignal.value['value'], - 'width': inputSignal.value['width'], + 'value': inputValue['value'], + 'width': inputValue['width'] }); inputSignalsList.add(signal); } - for (var outputSignal in json['outputs'].entries) { - SignalModel signal = SignalModel.fromMap({ + for (final outputSignal in outputsJson.entries) { + final outputValue = outputSignal.value as Map; + final signal = SignalModel.fromMap({ 'name': outputSignal.key, - 'direction': 'Input', - 'value': outputSignal.value['value'], - 'width': outputSignal.value['width'], + 'direction': 'Output', + 'value': outputValue['value'], + 'width': outputValue['width'] }); outputSignalsList.add(signal); } + for (final inoutSignal in inoutsJson.entries) { + final inoutValue = inoutSignal.value as Map; + final signal = SignalModel.fromMap({ + 'name': inoutSignal.key, + 'direction': 'Inout', + 'value': inoutValue['value'], + 'width': inoutValue['width'] + }); + + inoutSignalsList.add(signal); + } + return TreeModel( - name: json['name'], - inputs: inputSignalsList, - outputs: outputSignalsList, - subModules: (json["subModules"] as List) - .map((subModule) => TreeModel.fromJson(subModule)) - .toList(), - ); + name: json['name'] as String, + inputs: inputSignalsList, + outputs: outputSignalsList, + inouts: inoutSignalsList, + subModules: (json['subModules'] as List) + .map((subModule) => + TreeModel.fromJson(subModule as Map)) + .toList()); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart b/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart index 3bc48ff96..712b5587f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_devtools.dart @@ -6,8 +6,11 @@ // 2025 January 28 // Author: Roberto Torres +export 'cubit/details_tab_cubit.dart'; export 'cubit/rohd_service_cubit.dart'; export 'cubit/selected_module_cubit.dart'; export 'cubit/signal_search_term_cubit.dart'; +export 'cubit/snapshot_cubit.dart'; +export 'cubit/theme_cubit.dart'; export 'cubit/tree_search_term_cubit.dart'; export 'view/view.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart b/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart new file mode 100644 index 000000000..5f5f2789d --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart @@ -0,0 +1,618 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// connection_state_machine.dart +// State machine for managing the lifecycle of VM/DTD connections and +// the associated data (hierarchy, schematic, waveforms). +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:vm_service/vm_service.dart'; + +// --------------------------------------------------------------------------- +// Connection phases +// --------------------------------------------------------------------------- + +/// The coarse-grained connection phase. +/// +/// ```text +/// disconnected ──connect──▶ connecting ──success──▶ connected +/// ▲ │ +/// │ vm dies / user +/// │ disconnects +/// └────────────────────────────────────────────────┘ +/// +/// connected ──pause──▶ paused ──resume──▶ connected +/// │ +/// connected ──vm dies──▶ vmDead ──reconnect──▶ connected +/// ``` +enum ConnectionPhase { + /// No VM connection — user is on the connection page. + disconnected, + + /// WebSocket handshake + isolate discovery in progress. + connecting, + + /// VM connection is live. Sub-state tracked by [DataLoadState]. + connected, + + /// User deliberately paused the connection (data preserved in UI). + paused, + + /// VM was detected as dead (polling or DTD event). + vmDead, +} + +// --------------------------------------------------------------------------- +// Data load states +// --------------------------------------------------------------------------- + +/// What data has been successfully loaded from the VM. +/// +/// These flags are orthogonal — hierarchy and waveforms load independently. +/// The state machine uses them to decide what still needs loading when a +/// debug pause event arrives. +class DataLoadState { + /// Whether the module tree hierarchy has been loaded. + bool hierarchyLoaded; + + /// Whether schematic JSON has been loaded. + bool schematicLoaded; + + /// Whether initial waveform data has been fetched. + bool waveformDataLoaded; + + /// Whether we've attempted hierarchy loading and it returned null + /// (the ROHD app may not have finished building ModuleTree yet). + bool hierarchyAttempted; + + /// Creates a data-load snapshot. + DataLoadState({ + this.hierarchyLoaded = false, + this.schematicLoaded = false, + this.waveformDataLoaded = false, + this.hierarchyAttempted = false, + }); + + /// True when all essential data is present. + bool get isFullyLoaded => hierarchyLoaded; + + /// True when no data has been loaded yet. + bool get isEmpty => + !hierarchyLoaded && !schematicLoaded && !waveformDataLoaded; + + /// Reset all flags (e.g. on full reconnect to a new VM process). + /// Resets all data-load flags. + void reset() { + hierarchyLoaded = false; + schematicLoaded = false; + waveformDataLoaded = false; + hierarchyAttempted = false; + } + + /// Copy constructor for snapshotting state. + /// Creates a copy of this data-load snapshot. + DataLoadState copy() => DataLoadState( + hierarchyLoaded: hierarchyLoaded, + schematicLoaded: schematicLoaded, + waveformDataLoaded: waveformDataLoaded, + hierarchyAttempted: hierarchyAttempted, + ); + + @override + + /// Returns a debug string summarizing the current data-load state. + String toString() => 'DataLoadState(' + 'hierarchy=${hierarchyLoaded ? "✓" : hierarchyAttempted ? "✗" : "–"}, ' + 'schematic=${schematicLoaded ? "✓" : "–"}, ' + 'wfData=${waveformDataLoaded ? "✓" : "–"})'; +} + +// --------------------------------------------------------------------------- +// Connection identity +// --------------------------------------------------------------------------- + +/// Identity of a VM connection for detecting same-process reconnects. +class VmIdentity { + /// The VM service URI. + final String uri; + + /// The isolate ID (unique per VM process). + final String isolateId; + + /// Human-readable VM name (from DTD discovery). + final String? vmName; + + /// Creates a VM identity. + const VmIdentity({required this.uri, required this.isolateId, this.vmName}); + + /// Whether [other] represents the same running VM process. + /// + /// The Dart VM assigns a new isolate ID for every process, so matching + /// IDs prove the same process is still alive. + /// Returns whether [other] refers to the same VM process. + bool isSameProcess(VmIdentity other) => isolateId == other.isolateId; + + @override + + /// Returns a debug string describing this VM identity. + String toString() => 'VmIdentity(uri=$uri, isolate=$isolateId, ' + 'name=$vmName)'; +} + +// --------------------------------------------------------------------------- +// Events / transitions +// --------------------------------------------------------------------------- + +/// Events that drive the state machine. +/// +/// Each event carries any data needed for the transition. +sealed class ConnectionEvent { + const ConnectionEvent(); +} + +/// User initiated a connection to a VM service URI. +class ConnectRequested extends ConnectionEvent { + /// The URI requested by the user. + final String uri; + + /// Creates a connect-request event. + const ConnectRequested(this.uri); +} + +/// VM connection succeeded. +class ConnectionEstablished extends ConnectionEvent { + /// The connected VM service. + final VmService vmService; + + /// Identity of the connected VM. + final VmIdentity identity; + + /// Creates a connection-established event. + const ConnectionEstablished(this.vmService, this.identity); +} + +/// VM connection or data loading failed. +class ConnectionFailed extends ConnectionEvent { + /// Error message describing the failure. + final String error; + + /// Creates a connection-failed event. + const ConnectionFailed(this.error); +} + +/// User deliberately disconnected. +class DisconnectRequested extends ConnectionEvent { + /// Creates a disconnect-request event. + const DisconnectRequested(); +} + +/// User paused the VM connection (data preserved). +class PauseRequested extends ConnectionEvent { + /// Creates a pause-request event. + const PauseRequested(); +} + +/// User resumed a paused VM connection. +class ResumeRequested extends ConnectionEvent { + /// Creates a resume-request event. + const ResumeRequested(); +} + +/// VM was detected as dead (via polling or DTD event). +class VmDied extends ConnectionEvent { + /// Creates a VM-died event. + const VmDied(); +} + +/// VM came back to life (liveness check recovered). +class VmRecovered extends ConnectionEvent { + /// Creates a VM-recovered event. + const VmRecovered(); +} + +/// A debug pause event was received from the VM (breakpoint, exception, etc). +class DebugPauseReceived extends ConnectionEvent { + /// Kind of debug pause event. + final String kind; + + /// Creates a debug-pause event. + const DebugPauseReceived(this.kind); +} + +/// Hierarchy data was loaded (or attempted and returned null). +class HierarchyLoadResult extends ConnectionEvent { + /// Whether the hierarchy load succeeded. + final bool success; + + /// Creates a hierarchy-load result event. + const HierarchyLoadResult({required this.success}); +} + +/// A DTD event signalled that a new VM registered. +class DtdVmRegistered extends ConnectionEvent { + /// VM service URI reported by DTD. + final String uri; + + /// Optional human-readable name. + final String? name; + + /// Creates a DTD VM registered event. + const DtdVmRegistered(this.uri, {this.name}); +} + +/// A DTD event signalled that a VM was unregistered. +class DtdVmUnregistered extends ConnectionEvent { + /// Creates a DTD VM unregistered event. + const DtdVmUnregistered(); +} + +/// User entered demo/loopback mode. +class DemoModeEntered extends ConnectionEvent { + /// Creates a demo-mode event. + const DemoModeEntered(); +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +/// Callback signature for when the state machine wants the shell to load +/// hierarchy data. +typedef LoadHierarchyCallback = Future Function(); + +/// Callback signature for notifying the shell of state changes. +typedef StateChangeCallback = void Function( + ConnectionPhase phase, DataLoadState dataState); + +/// The connection state machine. +/// +/// Tracks the current [ConnectionPhase], [DataLoadState], and [VmIdentity]. +/// Emits [StateChangeCallback] whenever the state transitions so the UI +/// can update. +/// +/// ## Key design decisions +/// +/// 1. **No spinning on connect**: when the initial hierarchy load returns +/// null, we record `hierarchyAttempted = true` but do NOT retry in a +/// loop. Instead, when a [DebugPauseReceived] event arrives and +/// hierarchy is not yet loaded, we try again (exactly once per pause). +/// +/// 2. **Reconnect identity matching**: on reconnect, if the [VmIdentity] +/// has the same `isolateId` as before, we skip hierarchy/schematic +/// reload (the data is still valid). Only waveform data gets an +/// incremental pull. +/// +/// 3. **DTD events are authoritative**: when DTD says a VM died, we trust +/// it immediately (no additional liveness check). +class ConnectionStateMachine { + ConnectionPhase _phase = ConnectionPhase.disconnected; + final DataLoadState _dataState = DataLoadState(); + VmIdentity? _currentIdentity; + VmIdentity? _lastIdentity; + + /// Subscription to VM debug events for hierarchy-on-pause. + StreamSubscription? _debugEventSubscription; + + /// Debounce timer for debug pause events. + Timer? _pauseDebounceTimer; + static const _pauseDebounceDuration = Duration(milliseconds: 200); + + /// Whether a hierarchy load is currently in progress (prevents + /// concurrent loads from rapid breakpoints). + bool _hierarchyLoadInProgress = false; + + /// Callback invoked when the state machine needs hierarchy data loaded. + LoadHierarchyCallback? onLoadHierarchy; + + /// Callback invoked on every state transition. + StateChangeCallback? onStateChange; + + // ── Public getters ── + + /// Current connection phase. + ConnectionPhase get phase => _phase; + + /// Current data-load snapshot. + DataLoadState get dataState => _dataState; + + /// Identity of the currently connected VM, if any. + VmIdentity? get currentIdentity => _currentIdentity; + + /// Identity from the last successful connection, if any. + VmIdentity? get lastIdentity => _lastIdentity; + + /// Whether we're in a state where data loading makes sense. + bool get canLoadData => + _phase == ConnectionPhase.connected && _currentIdentity != null; + + /// Whether we should attempt hierarchy load on the next debug pause. + bool get shouldLoadHierarchyOnPause => + canLoadData && !_dataState.hierarchyLoaded; + + // ── State transitions ── + + /// Process an event and transition state accordingly. + void handleEvent(ConnectionEvent event) { + final oldPhase = _phase; + final oldDataSnapshot = _dataState.copy(); + + switch (event) { + case ConnectRequested(): + _onConnectRequested(event); + case ConnectionEstablished(): + _onConnectionEstablished(event); + case ConnectionFailed(): + _onConnectionFailed(event); + case DisconnectRequested(): + _onDisconnectRequested(); + case PauseRequested(): + _onPauseRequested(); + case ResumeRequested(): + _onResumeRequested(); + case VmDied(): + _onVmDied(); + case VmRecovered(): + _onVmRecovered(); + case DebugPauseReceived(): + _onDebugPause(event); + case HierarchyLoadResult(): + _onHierarchyLoadResult(event); + case DtdVmRegistered(): + _onDtdVmRegistered(event); + case DtdVmUnregistered(): + _onDtdVmUnregistered(); + case DemoModeEntered(): + _onDemoMode(); + } + + // Notify if anything changed + if (_phase != oldPhase || + _dataState.toString() != oldDataSnapshot.toString()) { + debugPrint('[CSM] ${oldPhase.name} → ${_phase.name} $_dataState'); + onStateChange?.call(_phase, _dataState); + } + } + + // ── Per-event handlers ── + + /// Handles a connect-request event. + void _onConnectRequested(ConnectRequested event) { + _phase = ConnectionPhase.connecting; + } + + /// Handles a successful VM connection. + void _onConnectionEstablished(ConnectionEstablished event) { + final isReconnectSameProcess = + _lastIdentity != null && _lastIdentity!.isSameProcess(event.identity); + + _currentIdentity = event.identity; + _phase = ConnectionPhase.connected; + + if (isReconnectSameProcess) { + // Same VM process — keep existing data, don't reload hierarchy. + debugPrint( + '[CSM] Reconnected to same process ' + '(${event.identity.isolateId}) — preserving data', + ); + } else { + // New process — reset data state so everything gets loaded fresh. + _dataState + ..reset() + ..hierarchyLoaded = false + ..schematicLoaded = false; + _hierarchyLoadInProgress = false; + _pauseDebounceTimer?.cancel(); + debugPrint( + '[CSM] Connected to new process ' + '(${event.identity.isolateId}) — data reset', + ); + } + } + + /// Handles a failed connection attempt. + void _onConnectionFailed(ConnectionFailed event) { + debugPrint('[CSM] Connection failed: ${event.error}'); + _phase = ConnectionPhase.disconnected; + } + + /// Handles a user-requested disconnect. + void _onDisconnectRequested() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _currentIdentity = null; + _dataState.reset(); + _hierarchyLoadInProgress = false; + _phase = ConnectionPhase.disconnected; + } + + /// Handles a user-requested pause. + void _onPauseRequested() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _currentIdentity = null; + // Data state is preserved — the UI keeps showing cached data. + _phase = ConnectionPhase.paused; + } + + /// Handles a user-requested resume. + void _onResumeRequested() { + // Phase transition happens when ConnectionEstablished arrives. + _phase = ConnectionPhase.connecting; + } + + /// Handles a VM death notification. + void _onVmDied() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _hierarchyLoadInProgress = false; + // Data state preserved — UI stays. + _phase = ConnectionPhase.vmDead; + } + + /// Handles a VM recovery notification. + void _onVmRecovered() { + if (_phase == ConnectionPhase.vmDead) { + _phase = ConnectionPhase.connected; + } + } + + /// Handles a debug pause event from the VM. + void _onDebugPause(DebugPauseReceived event) { + if (_phase != ConnectionPhase.connected) { + debugPrint('[CSM] Debug pause ignored — phase is ${_phase.name}'); + return; + } + + debugPrint( + '[CSM] Debug pause (${event.kind}), data: $_dataState, ' + 'shouldLoad=$shouldLoadHierarchyOnPause, ' + 'inProgress=$_hierarchyLoadInProgress', + ); + + // If hierarchy hasn't been loaded yet, try now. + // This is the key behavior: instead of spinning/polling after connect, + // we wait for the first debug pause event and load then. + if (shouldLoadHierarchyOnPause && !_hierarchyLoadInProgress) { + _hierarchyLoadInProgress = true; + debugPrint('[CSM] Hierarchy not loaded — requesting load on pause'); + _scheduleHierarchyLoad(); + } + } + + /// Schedules a debounced hierarchy load. + void _scheduleHierarchyLoad() { + // Debounce: if multiple pause events fire rapidly, only the last + // one triggers a load. + _pauseDebounceTimer?.cancel(); + _pauseDebounceTimer = Timer(_pauseDebounceDuration, _doHierarchyLoad); + } + + /// Performs the actual hierarchy load. + Future _doHierarchyLoad() async { + if (onLoadHierarchy == null) { + _hierarchyLoadInProgress = false; + return; + } + try { + await onLoadHierarchy!(); + } on Exception catch (e) { + debugPrint('[CSM] Hierarchy load failed: $e'); + } finally { + _hierarchyLoadInProgress = false; + } + } + + /// Handles the result of a hierarchy load. + void _onHierarchyLoadResult(HierarchyLoadResult event) { + _dataState.hierarchyAttempted = true; + _dataState.hierarchyLoaded = event.success; + if (event.success) { + debugPrint('[CSM] Hierarchy loaded successfully'); + } else { + debugPrint( + '[CSM] Hierarchy load returned null — will retry on next ' + 'debug pause', + ); + } + } + + /// Handles a DTD VM registration event. + void _onDtdVmRegistered(DtdVmRegistered event) { + // Handled by the shell — the state machine just records the event + // for logging. + debugPrint( + '[CSM] DTD: VM registered at ${event.uri} ' + '(name=${event.name})', + ); + } + + /// Handles a DTD VM unregistration event. + void _onDtdVmUnregistered() { + debugPrint('[CSM] DTD: VM unregistered'); + _onVmDied(); + } + + /// Switches the state machine into demo mode. + void _onDemoMode() { + _currentIdentity = null; + _lastIdentity = null; + // Mark hierarchy as loaded since demo mode provides it synchronously. + _dataState + ..reset() + ..hierarchyLoaded = true + ..schematicLoaded = true; + _phase = ConnectionPhase.connected; + } + + // ── Debug event subscription management ── + + /// Subscribe to VM debug events on the given [vmService]. + /// + /// When a pause event arrives, the state machine checks if hierarchy + /// data is missing and triggers a load. This replaces the old + /// "retry loop with exponential backoff" approach. + /// Subscribes to VM debug events. + Future subscribeToDebugEvents(VmService vmService) async { + await _cancelDebugSubscription(); + // Note: we deliberately do NOT call vmService.streamListen(Debug) here. + // The Debug stream is subscribed by ServiceManager.vmServiceOpened (the + // owner of the connection's stream lifecycle). Calling streamListen + // here as well would race with ServiceManager and produce an + // unhandled `Stream already subscribed (103)` error, because + // ServiceManager issues its streamListen via `unawaited(...)` inside a + // try/catch that only catches synchronous throws. + _debugEventSubscription = vmService.onDebugEvent.listen((event) { + final kind = event.kind; + if (kind == EventKind.kPauseBreakpoint || + kind == EventKind.kPauseException || + kind == EventKind.kPauseInterrupted || + kind == EventKind.kPauseExit) { + handleEvent(DebugPauseReceived(kind ?? 'unknown')); + } + }); + debugPrint('[CSM] Subscribed to debug events'); + } + + /// Cancels the current debug event subscription. + Future _cancelDebugSubscription() async { + _pauseDebounceTimer?.cancel(); + await _debugEventSubscription?.cancel(); + _debugEventSubscription = null; + } + + // ── Convenience queries ── + + /// Whether a reconnect to [identity] should skip hierarchy reload. + /// + /// Returns true when the last known VM has the same isolate ID, + /// meaning the same process is still running and its data hasn't + /// changed. + /// Returns true when hierarchy reload can be skipped for [identity]. + bool shouldSkipHierarchyReload(VmIdentity identity) => + _lastIdentity != null && + _lastIdentity!.isSameProcess(identity) && + _dataState.hierarchyLoaded; + + /// Marks waveform data as loaded. + void markWaveformDataLoaded() { + _dataState.waveformDataLoaded = true; + onStateChange?.call(_phase, _dataState); + } + + /// Marks schematic data as loaded. + void markSchematicLoaded() { + _dataState.schematicLoaded = true; + onStateChange?.call(_phase, _dataState); + } + + /// Disposes timers and subscriptions used by the state machine. + Future dispose() async { + await _cancelDebugSubscription(); + _pauseDebounceTimer?.cancel(); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart new file mode 100644 index 000000000..c8f2c2d27 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart @@ -0,0 +1,145 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// io_vm_connection_strategy.dart +// VM connection strategy for native (Linux/macOS/Windows) platforms. +// Uses vm_service_io for WebSocket connection. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:vm_service/vm_service_io.dart'; + +/// Simple logging class for VM service. +class _StdoutLog extends Log { + final Logger _logger = Logger('VMService'); + @override + void warning(String message) => _logger.warning(message); + + @override + void severe(String message) => _logger.severe(message); +} + +/// VM connection strategy for native platforms (Linux/macOS/Windows). +/// Uses vm_service_io's vmServiceConnectUri. +class IoVmConnectionStrategy extends VmConnectionStrategy { + @override + + /// Connects to a VM service on native platforms. + Future connect(String uri) async { + final normalizedUri = normalizeUri(uri); + + if (normalizedUri == null) { + throw Exception('Invalid URI format'); + } + + final vmService = await vmServiceConnectUri( + normalizedUri.toString(), + log: _StdoutLog(), + ).timeout( + const Duration(seconds: 10), + onTimeout: () => + throw TimeoutException('VM connection timed out after 10 s'), + ); + + final vm = await vmService.getVM().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException('getVM timed out after 5 s'), + ); + + // During a debugger restart the VM service endpoint becomes available + // before isolates are created, and the test isolate (which contains + // the ROHD inspector_service library) may lag behind the test-runner + // control isolate. Retry a few times with a short delay so we don't + // fall back to the slow polling reconnect path. + String? isolateId; + const maxRetries = 6; + const retryDelay = Duration(milliseconds: 500); + + for (var attempt = 1; attempt <= maxRetries; attempt++) { + final vmInfo = attempt == 1 + ? vm + : await vmService.getVM().timeout(const Duration(seconds: 3)); + final isolates = vmInfo.isolates ?? []; + + if (isolates.isEmpty) { + if (attempt < maxRetries) { + Logger('VMService').info( + 'No isolates yet (attempt $attempt/$maxRetries) — ' + 'waiting ${retryDelay.inMilliseconds} ms', + ); + await Future.delayed(retryDelay); + continue; + } + throw Exception( + 'No isolates found in the VM after $maxRetries ' + 'attempts (${retryDelay.inMilliseconds * maxRetries} ms)', + ); + } + + // Find the isolate that contains the ROHD inspector_service library. + for (final isolateRef in isolates) { + final id = isolateRef.id; + if (id == null) { + continue; + } + try { + final isolate = await vmService + .getIsolate(id) + .timeout(const Duration(milliseconds: 500)); + final libraries = isolate.libraries ?? []; + final hasRohd = libraries.any( + (lib) => + lib.uri != null && + lib.uri!.contains('rohd') && + lib.uri!.contains('inspector_service'), + ); + if (hasRohd) { + isolateId = id; + break; + } + } on Exception { + // Isolate not loaded yet or timed out — skip it + continue; + } + } + + if (isolateId != null) { + break; + } + + // Found isolates but none had ROHD — the test isolate may not + // have spawned yet. Retry unless this is the last attempt. + if (attempt < maxRetries) { + Logger('VMService').info( + 'ROHD isolate not found yet (attempt $attempt/$maxRetries, ' + '${isolates.length} isolate(s) seen) — retrying', + ); + await Future.delayed(retryDelay); + continue; + } + + // Last attempt — fall back to first isolate. + final fallback = isolates.first.id; + if (fallback == null) { + throw Exception('First isolate has no ID'); + } + isolateId = fallback; + Logger('VMService').info( + 'Isolate library scan incomplete after $maxRetries attempts — ' + 'using first isolate; evalModuleTree will verify', + ); + } + + return VmConnectionResult(vmService: vmService, isolateId: isolateId!); + } +} + +/// Returns an [IoVmConnectionStrategy]. Used by the conditional-import +/// dispatcher in `platform_vm_connection_strategy.dart`. +VmConnectionStrategy platformVmConnectionStrategy() => IoVmConnectionStrategy(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart new file mode 100644 index 000000000..59b3ed478 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_vm_connection_strategy.dart +// Conditional-import dispatcher that returns the correct +// [VmConnectionStrategy] for the current platform (IO vs. web). +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:rohd_devtools_extension/rohd_devtools/services/platform_vm_connection_strategy_stub.dart' + if (dart.library.io) 'package:rohd_devtools_extension/rohd_devtools/services/io_vm_connection_strategy.dart' + if (dart.library.js_interop) 'package:rohd_devtools_extension/rohd_devtools/services/web_vm_connection_strategy.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; + +/// Returns the platform-appropriate [VmConnectionStrategy]. +/// +/// On native (`dart:io`) platforms returns the IO strategy; +/// on web (`dart:js_interop`) platforms returns the web strategy. +VmConnectionStrategy createPlatformVmConnectionStrategy() => + platformVmConnectionStrategy(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart new file mode 100644 index 000000000..8e56964d0 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_vm_connection_strategy_stub.dart +// Stub fallback for [createPlatformVmConnectionStrategy]. +// Real implementations live in [io_vm_connection_strategy.dart] and +// [web_vm_connection_strategy.dart] and are selected via conditional +// imports in [platform_vm_connection_strategy.dart]. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; + +/// Stub that throws when neither `dart:io` nor `dart:js_interop` is available. +/// Returns the platform VM connection strategy, or throws on unsupported +/// targets. +VmConnectionStrategy platformVmConnectionStrategy() { + throw UnsupportedError( + 'No VmConnectionStrategy available for the current platform.', + ); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart new file mode 100644 index 000000000..8da322797 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge.dart +// Conditional bridge that exports the platform-specific ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +export 'service_manager_bridge_io.dart' + if (dart.library.js_interop) 'service_manager_bridge_web.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart new file mode 100644 index 000000000..1c470c5db --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart @@ -0,0 +1,15 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge_io.dart +// Native implementation for exposing a local DevTools ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:devtools_app_shared/service.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +/// Native fallback: keep an app-local ServiceManager instance. +final ServiceManager serviceManager = + ServiceManager(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart new file mode 100644 index 000000000..67a2683d9 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge_web.dart +// Web implementation that re-exports DevTools extension ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +export 'package:devtools_extensions/devtools_extensions.dart' + show serviceManager; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/services.dart b/rohd_devtools_extension/lib/rohd_devtools/services/services.dart new file mode 100644 index 000000000..1445ae974 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/services.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// services.dart +// Barrel file for rohd_devtools services. +// +// NOTE: io_vm_connection_strategy.dart and web_vm_connection_strategy.dart +// are excluded because they have platform-specific dependencies. + +export 'connection_state_machine.dart'; +export 'platform_vm_connection_strategy.dart'; +export 'service_manager_bridge.dart'; +export 'signal_service.dart'; +export 'signal_value_source.dart'; +export 'tree_service.dart'; +export 'vm_service_signal_value_source.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart b/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart index ba4c5cc0d..d0844648f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // signal_service.dart @@ -9,14 +9,16 @@ import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +/// Utility methods for signal filtering and lookup. abstract class SignalService { + /// Filters signals by case-insensitive name match. static List filterSignals( List signals, String searchTerm, ) { - List filteredSignals = []; + final filteredSignals = []; - for (var signal in signals) { + for (final signal in signals) { if (signal.name.toLowerCase().contains(searchTerm.toLowerCase())) { filteredSignals.add(signal); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source.dart b/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source.dart new file mode 100644 index 000000000..5937a6824 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source.dart @@ -0,0 +1,42 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_value_source.dart +// Shared extension-side abstraction for fetching live signal values. +// +// 2026 June +// Author: Desmond Kirkpatrick + +/// Raw snapshot payload keyed by signal ID or full signal path. +typedef SignalSnapshotData = Map>; + +/// Minimal update event used by the shared snapshot overlay flow. +class SignalValueUpdateEvent { + /// Simulation time covered by the update, in source-provided units. + final int upToTime; + + /// Whether the update indicates value data is available. + final bool hasData; + + /// Human-readable update reason for diagnostics. + final String reason; + + /// Creates a [SignalValueUpdateEvent]. + const SignalValueUpdateEvent({ + required this.upToTime, + required this.hasData, + required this.reason, + }); +} + +/// Shared abstraction for value-only snapshot fetches and update ticks. +abstract interface class SignalValueSource { + /// Stream of live update ticks, if this source supports them. + Stream? get updates; + + /// Returns the current simulation time, if available. + Future getCurrentTime(); + + /// Fetch a value snapshot at [time]. + Future getSnapshot(int time); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source_binding.dart b/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source_binding.dart new file mode 100644 index 000000000..79e883bfc --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/signal_value_source_binding.dart @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_value_source_binding.dart +// Repo-specific binding for live signal value source creation. + +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/tree_service.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/vm_service_signal_value_source.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +/// Creates the repo-specific live signal value source for [treeService]. +SignalValueSource? createSignalValueSourceBinding({ + required TreeService treeService, + required vm.VmService vmService, +}) => + VmServiceSignalValueSource( + rohdControllerEval: treeService.rohdControllerEval, + evalDisposable: treeService.evalDisposable, + vmService: vmService, + ); + +/// Dispose any repo-specific live signal value source instance. +Future disposeSignalValueSourceBinding( + SignalValueSource? signalValueSource, +) async { + if (signalValueSource is VmServiceSignalValueSource) { + await signalValueSource.dispose(); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart index 578134c52..7701234b6 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree_service.dart @@ -10,39 +10,87 @@ import 'dart:convert'; import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; +import 'package:vm_service/vm_service.dart'; +/// Service helpers for evaluating and filtering the ROHD module tree. class TreeService { - final invokeFunc = 'ModuleTree.instance.hierarchyJSON'; + /// Primary expression for hierarchy JSON — available in all ROHD versions + /// that ship inspector_service.dart (i.e. main and later). + static const _primaryInvokeFunc = 'ModuleTree.instance.hierarchyJSON'; + + /// Fallback kept for any pre-inspector ROHD target. + static const _legacyInvokeFunc = 'ModuleTree.instance.hierarchyJSON'; + + /// Eval wrapper for accessing ROHD code in the target isolate. final EvalOnDartLibrary rohdControllerEval; + + /// Disposable token used to keep the eval alive. final Disposable evalDisposable; - TreeService(this.rohdControllerEval, this.evalDisposable); + /// Optional VM service for source-line lookups (cross-probe). + final VmService? vmService; + + /// Optional isolate ID used with [vmService]. + final String? isolateId; + + /// Creates a tree service around the given eval wrapper. + TreeService(this.rohdControllerEval, this.evalDisposable, + {this.vmService, this.isolateId}); + /// Evaluates the module tree from the ROHD service. Future evalModuleTree() async { - final treeInstance = await rohdControllerEval.evalInstance( - invokeFunc, - isAlive: evalDisposable, - ); + final payload = await _evalTreePayload(); + if (payload == null || payload.isEmpty) { + debugPrint('[TreeService] evalModuleTree failed: empty payload'); + return null; + } - final treeObj = jsonDecode(treeInstance.valueAsString ?? '') as Map; + final decoded = jsonDecode(payload); + if (decoded is! Map) { + debugPrint('[TreeService] evalModuleTree failed: unexpected payload type ' + '${decoded.runtimeType}'); + return null; + } - if (treeObj['status'] == 'fail') { - print('error'); + final treeObj = decoded; + if (treeObj['status'] == 'fail' || treeObj['status'] == 'unavailable') { + final message = + treeObj['message'] ?? treeObj['reason'] ?? treeObj['error']; + debugPrint('[TreeService] evalModuleTree failed: $message'); return null; - } else { - return TreeModel.fromJson(jsonDecode(treeInstance.valueAsString ?? "")); } + + return TreeModel.fromJson(treeObj); + } + + Future _evalTreePayload() async { + final expressions = [_primaryInvokeFunc, _legacyInvokeFunc]; + + for (final expression in expressions) { + try { + final treeInstance = await rohdControllerEval.evalInstance(expression, + isAlive: evalDisposable); + return treeInstance.valueAsString; + } on Exception catch (e) { + debugPrint('[TreeService] Eval failed for "$expression": $e'); + } + } + + return null; } + /// Returns whether the current module or any descendant matches the search. static bool isNodeOrDescendentMatching( TreeModel module, String? treeSearchTerm) { if (module.name.toLowerCase().contains(treeSearchTerm!.toLowerCase())) { return true; } - for (TreeModel childModule in module.subModules) { + for (final childModule in module.subModules) { if (isNodeOrDescendentMatching(childModule, treeSearchTerm)) { return true; } @@ -50,10 +98,12 @@ class TreeService { return false; } - Future refreshModuleTree() { - return rohdControllerEval - .evalInstance(invokeFunc, isAlive: evalDisposable) - .then((treeInstance) => - TreeModel.fromJson(jsonDecode(treeInstance.valueAsString ?? "{}"))); + /// Refreshes the module tree from the ROHD service. + Future refreshModuleTree() async { + final treeModel = await evalModuleTree(); + if (treeModel == null) { + throw StateError('Failed to refresh module tree.'); + } + return treeModel; } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart b/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart new file mode 100644 index 000000000..c1be04bfd --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart @@ -0,0 +1,382 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// vm_service_signal_value_source.dart +// VM-backed adapter for the shared signal value source interface. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:async'; +import 'dart:convert'; + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +/// VM-backed [SignalValueSource] that refreshes on debugger pause events. +class VmServiceSignalValueSource implements SignalValueSource { + static const _moduleTreeHierarchyExpression = + 'ModuleTree.instance.hierarchyJSON'; + + static const _currentTimeExpressions = [ + 'WaveformService.instance.currentTime', + ]; + + static const _currentTimeExtension = 'ext.rohd.currentTime'; + static const _snapshotExtension = 'ext.rohd.snapshotCompact'; + + /// Eval wrapper for ROHD-side expressions. + final EvalOnDartLibrary rohdControllerEval; + + /// Disposable token that keeps the eval alive. + final Disposable evalDisposable; + + /// VM service used for debug-event subscriptions. + final vm.VmService vmService; + + final StreamController _updatesController = + StreamController.broadcast(); + + StreamSubscription? _debugEventSubscription; + int _lastKnownTime = 0; + int _syntheticUpdateTime = 0; + + /// Creates a VM-backed signal value source. + VmServiceSignalValueSource({ + required this.rohdControllerEval, + required this.evalDisposable, + required this.vmService, + }) { + _debugEventSubscription = vmService.onDebugEvent.listen( + _handleDebugEvent, + onError: (Object e) { + debugPrint('[VmSignalValueSource] Debug stream error: $e'); + }, + ); + } + + @override + Stream get updates => _updatesController.stream; + + @override + Future getCurrentTime() async { + final extensionTime = await _readCurrentTimeFromExtension(); + if (extensionTime != null && extensionTime > 0) { + _rememberTime(extensionTime); + return extensionTime; + } + + for (final expression in _currentTimeExpressions) { + try { + final value = await rohdControllerEval.evalInstance( + expression, + isAlive: evalDisposable, + ); + final raw = value.valueAsString; + if (raw == null || raw.isEmpty) { + continue; + } + + final parsedInt = int.tryParse(raw); + if (parsedInt != null) { + _rememberTime(parsedInt); + return parsedInt; + } + + final decoded = jsonDecode(raw); + if (decoded is int) { + _rememberTime(decoded); + return decoded; + } + if (decoded is Map && decoded['currentTime'] is int) { + final currentTime = decoded['currentTime'] as int; + _rememberTime(currentTime); + return currentTime; + } + } on Exception catch (e) { + debugPrint( + '[VmSignalValueSource] Current time eval failed ' + 'for "$expression": $e', + ); + } + } + + return null; + } + + @override + Future getSnapshot(int time) async { + final extensionSnapshot = await _readSnapshotFromExtension(time); + if (extensionSnapshot != null) { + return extensionSnapshot; + } + + final snapshotExpressions = [ + _moduleTreeHierarchyExpression, + _moduleTreeSignalValuesExpression, + _waveformSnapshotExpression(time), + ]; + + for (final expression in snapshotExpressions) { + try { + final value = await rohdControllerEval.evalInstance( + expression, + isAlive: evalDisposable, + ); + final payload = value.valueAsString; + if (payload == null || payload.isEmpty) { + continue; + } + + final decoded = jsonDecode(payload); + if (decoded is! Map) { + continue; + } + + if (decoded['status'] == 'fail' || decoded['status'] == 'unavailable') { + debugPrint( + '[VmSignalValueSource] Snapshot unavailable: ' + '${decoded['message'] ?? decoded['reason'] ?? decoded['error']}', + ); + continue; + } + + if (expression == _moduleTreeHierarchyExpression) { + final hierarchySignals = _decodeHierarchySnapshot(decoded); + if (hierarchySignals != null) { + return hierarchySignals; + } + continue; + } + + final rawSignals = decoded['signals'] is Map + ? decoded['signals'] as Map + : decoded; + + final signals = >{}; + for (final entry in rawSignals.entries) { + final data = entry.value; + if (data is Map) { + signals[entry.key] = data; + } + } + + if (signals.isNotEmpty) { + return signals; + } + } on Exception catch (e) { + debugPrint( + '[VmSignalValueSource] Snapshot eval failed ' + 'for "$expression": $e', + ); + } + } + + return null; + } + + /// Dispose stream subscriptions owned by this source. + Future dispose() async { + await _debugEventSubscription?.cancel(); + _debugEventSubscription = null; + await _updatesController.close(); + } + + static String _waveformSnapshotExpression(int time) => + 'WaveformService.instance.getSnapshotCompactJSON($time)'; + + static const _moduleTreeSignalValuesExpression = + 'ModuleTree.instance.signalValuesJSON'; + + Future _readCurrentTimeFromExtension() async { + final response = await _callExtension(_currentTimeExtension); + if (response == null) { + return null; + } + + final currentTime = response['currentTime']; + if (currentTime is int) { + return currentTime; + } + return null; + } + + Future _readSnapshotFromExtension(int time) async { + final response = await _callExtension( + _snapshotExtension, + args: {'time': time.toString()}, + ); + if (response == null) { + return null; + } + + return _decodeSnapshotPayload(response); + } + + Future?> _callExtension( + String method, { + Map? args, + }) async { + try { + final response = await vmService.callServiceExtension(method, args: args); + return response.json; + } on Exception { + return null; + } + } + + SignalSnapshotData? _decodeSnapshotPayload(Map decoded) { + if (decoded['status'] == 'fail' || decoded['status'] == 'unavailable') { + debugPrint( + '[VmSignalValueSource] Snapshot unavailable: ' + '${decoded['message'] ?? decoded['reason'] ?? decoded['error']}', + ); + return null; + } + + final rawSignals = decoded['signals'] is Map + ? decoded['signals'] as Map + : decoded; + + final signals = >{}; + for (final entry in rawSignals.entries) { + final data = entry.value; + if (data is Map) { + signals[entry.key] = data; + } + } + + return signals.isEmpty ? null : signals; + } + + SignalSnapshotData? _decodeHierarchySnapshot(Map decoded) { + final signals = >{}; + _collectHierarchySignals(decoded, signals, parentPath: ''); + return signals.isEmpty ? null : signals; + } + + void _collectHierarchySignals( + Map moduleJson, + Map> signals, { + required String parentPath, + }) { + final moduleName = moduleJson['name'] as String?; + final modulePath = moduleName == null || moduleName.isEmpty + ? parentPath + : parentPath.isEmpty + ? moduleName + : '$parentPath.$moduleName'; + + _collectSignalGroup( + moduleJson['inputs'], + direction: 'Input', + modulePath: modulePath, + signals: signals, + ); + _collectSignalGroup( + moduleJson['outputs'], + direction: 'Output', + modulePath: modulePath, + signals: signals, + ); + _collectSignalGroup( + moduleJson['inouts'], + direction: 'Inout', + modulePath: modulePath, + signals: signals, + ); + + final subModules = moduleJson['subModules']; + if (subModules is! List) { + return; + } + + for (final child in subModules) { + if (child is Map) { + _collectHierarchySignals(child, signals, parentPath: modulePath); + } + } + } + + void _collectSignalGroup( + Object? groupJson, { + required String direction, + required String modulePath, + required Map> signals, + }) { + if (groupJson is! Map) { + return; + } + + for (final entry in groupJson.entries) { + final signalName = entry.key; + final data = entry.value; + if (data is! Map) { + continue; + } + + final signalPath = + modulePath.isEmpty ? signalName : '$modulePath.$signalName'; + signals[signalPath] = { + 'name': signalName, + 'value': data['value']?.toString() ?? '?', + 'width': _decodeWidth(data['width']), + 'direction': direction, + }; + } + } + + int _decodeWidth(Object? widthValue) { + if (widthValue is int) { + return widthValue; + } + if (widthValue is String) { + return int.tryParse(widthValue) ?? 1; + } + return 1; + } + + void _rememberTime(int time) { + if (time <= 0) { + return; + } + + _lastKnownTime = time; + if (_syntheticUpdateTime < time) { + _syntheticUpdateTime = time; + } + } + + int _nextUpdateTime() { + if (_lastKnownTime > _syntheticUpdateTime) { + _syntheticUpdateTime = _lastKnownTime; + } + + return ++_syntheticUpdateTime; + } + + void _handleDebugEvent(vm.Event event) { + final kind = event.kind; + + if (kind == null || !kind.startsWith('Pause')) { + return; + } + + unawaited(_emitPauseUpdate(kind)); + } + + Future _emitPauseUpdate(String reason) async { + if (_updatesController.isClosed) { + return; + } + + final time = _nextUpdateTime(); + + _updatesController.add( + SignalValueUpdateEvent(upToTime: time, hasData: true, reason: reason), + ); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart new file mode 100644 index 000000000..535aff769 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// web_vm_connection_strategy.dart +// VM connection strategy for web platforms. +// Uses web_socket_channel for browser-compatible WebSocket connection. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Simple logging class for VM service. +class _WebLog extends Log { + final Logger _logger = Logger('VMService.Web'); + @override + void warning(String message) => _logger.warning(message); + + @override + void severe(String message) => _logger.severe(message); +} + +/// VM connection strategy for web platforms (browser). +/// Uses web_socket_channel instead of dart:io WebSocket. +class WebVmConnectionStrategy extends VmConnectionStrategy { + void _logWebSocketError(Object error) { + Logger('VMService.Web').severe('WebSocket error: $error'); + } + + @override + Future connect(String uri) async { + final normalizedUri = normalizeUri(uri); + + if (normalizedUri == null) { + throw Exception('Invalid URI format'); + } + + final wsUrl = normalizedUri.toString(); + final channel = WebSocketChannel.connect(Uri.parse(wsUrl)); + final socketSink = channel.sink; + + // Wait for the connection to be established. + await channel.ready.timeout( + const Duration(seconds: 10), + onTimeout: () => + throw TimeoutException('WebSocket connection timed out after 10 s'), + ); + + final controller = StreamController(); + final streamClosedCompleter = Completer(); + + channel.stream.listen( + controller.add, + onDone: streamClosedCompleter.complete, + onError: _logWebSocketError, + ); + + final vmService = VmService( + controller.stream, + socketSink.add, + log: _WebLog(), + disposeHandler: () async { + await controller.close(); + await socketSink.close(); + }, + streamClosed: streamClosedCompleter.future, + wsUri: wsUrl, + ); + + final vm = await vmService.getVM().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException('getVM timed out after 5 s'), + ); + + // During a debugger restart the VM service endpoint becomes available + // before isolates are created, and the test isolate (which contains + // the ROHD inspector_service library) may lag behind the test-runner + // control isolate. Retry a few times with a short delay so we don't + // fall back to the slow polling reconnect path. + String? isolateId; + const maxRetries = 6; + const retryDelay = Duration(milliseconds: 500); + + for (var attempt = 1; attempt <= maxRetries; attempt++) { + final vmInfo = attempt == 1 + ? vm + : await vmService.getVM().timeout(const Duration(seconds: 3)); + final isolates = vmInfo.isolates ?? []; + + if (isolates.isEmpty) { + if (attempt < maxRetries) { + Logger('VMService.Web').info( + 'No isolates yet (attempt $attempt/$maxRetries) — ' + 'waiting ${retryDelay.inMilliseconds} ms', + ); + await Future.delayed(retryDelay); + continue; + } + throw Exception( + 'No isolates found in the VM after $maxRetries ' + 'attempts (${retryDelay.inMilliseconds * maxRetries} ms)', + ); + } + + // Find the isolate that contains the ROHD inspector_service library. + for (final isolateRef in isolates) { + final id = isolateRef.id; + if (id == null) { + continue; + } + try { + final isolate = await vmService + .getIsolate(id) + .timeout(const Duration(milliseconds: 500)); + final libraries = isolate.libraries ?? []; + final hasRohd = libraries.any( + (lib) => + lib.uri != null && + lib.uri!.contains('rohd') && + lib.uri!.contains('inspector_service'), + ); + if (hasRohd) { + isolateId = id; + break; + } + } on Exception { + // Isolate not loaded yet or timed out — skip it + continue; + } + } + + if (isolateId != null) { + break; + } + + // Found isolates but none had ROHD — the test isolate may not + // have spawned yet. Retry unless this is the last attempt. + if (attempt < maxRetries) { + Logger('VMService.Web').info( + 'ROHD isolate not found yet (attempt $attempt/$maxRetries, ' + '${isolates.length} isolate(s) seen) — retrying', + ); + await Future.delayed(retryDelay); + continue; + } + + // Last attempt — fall back to first isolate. + final fallback = isolates.first.id; + if (fallback == null) { + throw Exception('First isolate has no ID'); + } + isolateId = fallback; + Logger('VMService.Web').info( + 'Isolate library scan incomplete after $maxRetries attempts — ' + 'using first isolate; evalModuleTree will verify', + ); + } + + return VmConnectionResult(vmService: vmService, isolateId: isolateId!); + } +} + +/// Returns a [WebVmConnectionStrategy]. Used by the conditional-import +/// dispatcher in `platform_vm_connection_strategy.dart`. +VmConnectionStrategy platformVmConnectionStrategy() => + WebVmConnectionStrategy(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart new file mode 100644 index 000000000..6fe12d1e7 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart @@ -0,0 +1,40 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// details_help_button.dart +// Help button widget for the Details tab. +// +// Content is loaded from assets/help/details_help.md. +// Edit that markdown file to update hover tooltip and dialog content. +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the Details tab. +/// +/// Content is driven by `assets/help/details_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DetailsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DetailsHelpButton]. + const DetailsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/details_help.md', + isDark: isDark, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('isDark', value: isDark)); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart index 9138fc191..7c8d7e022 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // devtool_appbar.dart @@ -7,41 +7,77 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_help_button.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; +/// App bar used by the ROHD DevTools UI. class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { - const DevtoolAppBar({ - super.key, - }); + /// Whether to render color emoji icons where available. + const DevtoolAppBar({super.key, this.hasColorEmoji = kIsWeb}); + + /// Whether the icon set should prefer color emoji glyphs. + final bool hasColorEmoji; @override + + /// Builds the app bar with help, license, and theme controls. Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accentColor = Theme.of(context).colorScheme.primary; + return AppBar( - backgroundColor: Theme.of(context).colorScheme.onPrimary, - title: const Text('ROHD DevTool (Beta)'), - leading: const Icon(Icons.build), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 20.0), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - showLicensePage(context: context); - }, - child: const Text( - 'Licenses', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ); + backgroundColor: Theme.of(context).colorScheme.onPrimary, + title: const Text('ROHD DevTool (Beta)'), + leading: Padding( + padding: const EdgeInsets.all(8), + child: + Image.asset('assets/icons/rohd_logo.png', fit: BoxFit.contain)), + actions: [ + // ── Help ── + DevToolsHelpButton(isDark: isDark), + + // ── Licenses ── + Padding( + padding: const EdgeInsets.only(right: 20), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showLicensePage(context: context); + }, + child: const Text('Licenses', + style: TextStyle(fontWeight: FontWeight.bold))))), + + BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + return IconButton( + tooltip: + isDark ? 'Switch to light theme' : 'Switch to dark theme', + onPressed: () { + context.read().toggleTheme(); + }, + icon: platformIcon(isDark ? Icons.light_mode : Icons.dark_mode, + isDark ? '☀️' : '🌙', + size: 24, + color: accentColor, + hasColorEmoji: hasColorEmoji)); + }) + ]); } @override + + /// The preferred height of the app bar. Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('hasColorEmoji', value: hasColorEmoji)); + } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart new file mode 100644 index 000000000..d6b80eac1 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart @@ -0,0 +1,1568 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_connection_host.dart +// Abstract base class for DevTools app shells that manage VM/DTD connection +// lifecycle. Subclasses provide app-specific data loading and UI. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:dtd/dtd.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart' hide Stack; + +/// Discover the VM services currently advertised by a DTD endpoint. +/// +/// If [onRegisteredServices] is provided, it is invoked with the set of +/// currently registered DTD client service names before VM service discovery +/// completes. +Future> discoverVmServicesViaDtd( + String dtdUri, { + void Function(Set serviceNames)? onRegisteredServices, +}) async { + debugPrint('[ConnectionHost] Connecting to DTD at: $dtdUri'); + final dtd = await DartToolingDaemon.connect(Uri.parse(dtdUri)); + + try { + if (onRegisteredServices != null) { + try { + final registered = await dtd.getRegisteredServices(); + final serviceNames = { + for (final svc in registered.clientServices) svc.name, + }; + onRegisteredServices(serviceNames); + debugPrint('[ConnectionHost] Registered services: $serviceNames'); + } on Exception catch (e) { + debugPrint('[ConnectionHost] getRegisteredServices failed: $e'); + } + } + + final response = await dtd.getVmServices(); + final services = response.vmServicesInfos; + + debugPrint('[ConnectionHost] Found ${services.length} VM service(s)'); + for (final svc in services) { + debugPrint( + '[ConnectionHost] ${svc.name ?? "(unnamed)"}: ' + 'uri=${svc.uri}, exposedUri=${svc.exposedUri}', + ); + } + + return services + .map( + (svc) => DiscoveredVmService( + name: svc.name, + uri: svc.uri, + exposedUri: svc.exposedUri, + ), + ) + .toList(); + } finally { + await dtd.close(); + } +} + +/// The resolved inputs for a user-driven connection attempt. +/// +/// Carries the cleaned URI strings plus either the chosen VM service URI or +/// a user-facing validation error. +class VmConnectionAttemptResolution { + /// The VM service URI to connect to, if resolution succeeded. + final String? vmServiceUri; + + /// The cleaned VM service URI derived from the user's input. + final String cleanedVmServiceUri; + + /// The cleaned DTD URI derived from the user's input. + final String cleanedDtdUri; + + /// A user-facing validation or discovery error, when resolution fails. + final String? error; + + /// Creates a resolved connection-attempt payload. + const VmConnectionAttemptResolution({ + required this.vmServiceUri, + required this.cleanedVmServiceUri, + required this.cleanedDtdUri, + this.error, + }); +} + +/// Resolve a connection attempt from raw VM service and DTD URI inputs. +/// +/// If the VM service URI is already valid, it is used directly. Otherwise the +/// DTD endpoint is queried via [discoverVmServices] and the first advertised VM +/// service is selected. +Future resolveVmConnectionAttempt({ + required String rawVmServiceUri, + required String rawDtdUri, + required Future> Function(String dtdUri) + discoverVmServices, +}) async { + final vmServiceUri = DevToolsConnectionHostState.cleanVmServiceUri( + rawVmServiceUri, + ); + var dtdUri = ''; + if (rawDtdUri.isNotEmpty) { + dtdUri = DevToolsConnectionHostState.cleanDtdUri(rawDtdUri); + } + + final hasVmUri = vmServiceUri.isNotEmpty && + vmServiceUri.startsWith('ws') && + !vmServiceUri.contains('xxxx'); + final hasDtdUri = dtdUri.isNotEmpty && dtdUri.startsWith('ws'); + + if (!hasVmUri && !hasDtdUri) { + return VmConnectionAttemptResolution( + vmServiceUri: null, + cleanedVmServiceUri: vmServiceUri, + cleanedDtdUri: dtdUri, + error: 'Please enter a VM Service URI or DTD URI', + ); + } + + if (hasVmUri) { + return VmConnectionAttemptResolution( + vmServiceUri: vmServiceUri, + cleanedVmServiceUri: vmServiceUri, + cleanedDtdUri: dtdUri, + ); + } + + final services = await discoverVmServices(dtdUri); + if (services.isEmpty) { + return VmConnectionAttemptResolution( + vmServiceUri: null, + cleanedVmServiceUri: vmServiceUri, + cleanedDtdUri: dtdUri, + error: 'No VM services found via DTD. Is your ROHD app running?', + ); + } + + return VmConnectionAttemptResolution( + vmServiceUri: services.first.connectionUri, + cleanedVmServiceUri: vmServiceUri, + cleanedDtdUri: dtdUri, + ); +} + +/// Whether a DTD VM lifecycle event refers to the VM currently tracked by the +/// host. +/// +/// Matches against either the direct URI or the exposed URI reported by DTD. +bool dtdEventMatchesTrackedVm({ + required String? trackedVmUri, + required String? eventUri, + required String? eventExposedUri, +}) { + bool matchesCandidate(String? candidate) { + if (candidate == null || candidate.isEmpty) { + return false; + } + if (trackedVmUri == null || trackedVmUri.isEmpty) { + return true; + } + return trackedVmUri.contains(candidate) || candidate.contains(trackedVmUri); + } + + if (trackedVmUri == null || trackedVmUri.isEmpty) { + return true; + } + + return matchesCandidate(eventUri) || matchesCandidate(eventExposedUri); +} + +/// Return the preferred VM service URI carried by a DTD lifecycle event. +/// +/// Prefers the externally connectable exposed URI when present, otherwise +/// falls back to the raw URI field. +String? preferredVmServiceUriFromDtdEvent(DTDEvent event) { + final eventUri = event.data[DtdParameters.uri]?.toString(); + final eventExposedUri = event.data[DtdParameters.exposedUri]?.toString(); + if (eventExposedUri != null && eventExposedUri.isNotEmpty) { + return eventExposedUri; + } + if (eventUri != null && eventUri.isNotEmpty) { + return eventUri; + } + return null; +} + +// --------------------------------------------------------------------------- +// VM Connection Strategy +// --------------------------------------------------------------------------- + +/// Abstract base for VM connection strategies. +/// Linux uses vm_service_io, Web uses package:web WebSocket. +abstract class VmConnectionStrategy { + /// Connect to VM service at the given URI. + /// Returns the VmService and isolateId for the main isolate. + Future connect(String uri); + + /// Normalize URI to websocket format. + Uri? normalizeUri(String value) { + try { + var uri = Uri.parse(value.trim()); + + if (uri.scheme == 'http') { + uri = uri.replace(scheme: 'ws'); + } else if (uri.scheme == 'https') { + uri = uri.replace(scheme: 'wss'); + } + + if (!uri.path.endsWith('/ws')) { + uri = uri.replace(path: '${uri.path}ws'); + } + + return uri; + } on Exception { + return null; + } + } +} + +/// Result of a VM connection attempt. +class VmConnectionResult { + /// The connected VM service. + final VmService vmService; + + /// The isolate ID of the main isolate. + final String isolateId; + + /// Constructor for [VmConnectionResult]. + VmConnectionResult({required this.vmService, required this.isolateId}); +} + +/// Describes whether an attach is brand-new, a same-VM restart, or a +/// teardown for disconnect. +enum VmConnectionTransitionKind { + /// A brand-new VM attachment with no preserved prior app state. + freshAttach, + + /// A reconnect to the same logical VM after a restart. + sameVmRestart, + + /// A teardown transition driven by explicit disconnect. + disconnect, +} + +/// Carries the previous VM identity into reconnect hooks so subclasses can +/// preserve app state only for same-VM restarts. +class VmConnectionTransition { + /// The host-classified reconnect kind for this attach. + final VmConnectionTransitionKind kind; + + /// The VM service URI that was attached before this transition. + final String? previousUri; + + /// The isolate ID that was attached before this transition. + final String? previousIsolateId; + + /// The last tracked VM name, when known. + final String? previousVmName; + + /// Creates a transition with the given classification and prior identity. + const VmConnectionTransition({ + required this.kind, + this.previousUri, + this.previousIsolateId, + this.previousVmName, + }); + + /// Creates a fresh attach transition. + const VmConnectionTransition.fresh() + : this(kind: VmConnectionTransitionKind.freshAttach); + + /// Creates a same-VM restart transition. + const VmConnectionTransition.sameVmRestart() + : this(kind: VmConnectionTransitionKind.sameVmRestart); + + /// Creates a disconnect transition. + const VmConnectionTransition.disconnect() + : this(kind: VmConnectionTransitionKind.disconnect); + + /// Whether the app should preserve widget state through this transition. + bool get preservesAppState => + kind == VmConnectionTransitionKind.sameVmRestart; + + /// Whether the old and new attachments represent the same logical VM. + bool get isSameLogicalVm => kind == VmConnectionTransitionKind.sameVmRestart; + + /// Returns a copy populated with the previous VM identity captured by the + /// host at reconnect time. + VmConnectionTransition withPrevious({ + String? previousUri, + String? previousIsolateId, + String? previousVmName, + }) => + VmConnectionTransition( + kind: kind, + previousUri: previousUri ?? this.previousUri, + previousIsolateId: previousIsolateId ?? this.previousIsolateId, + previousVmName: previousVmName ?? this.previousVmName, + ); +} + +class _ReconnectState { + final String? connectedVmName; + final bool autoReconnect; + + const _ReconnectState({ + required this.connectedVmName, + required this.autoReconnect, + }); +} + +// --------------------------------------------------------------------------- +// DevToolsConnectionHost base class +// --------------------------------------------------------------------------- + +/// Abstract base State that manages VM/DTD connection lifecycle. +/// +/// Subclasses (e.g. the ROHD DevTools page) extend this to get: +/// - VM connect / disconnect / pause / resume / lightweight reconnect +/// - Persistent DTD connection with VmServiceRegistered/Unregistered events +/// - DTD Service stream for extension availability (e.g. 'rohd' service) +/// - VM liveness polling with auto-reconnect by name +/// - ConnectionStateMachine integration +/// - Connection dialog management +/// +/// The subclass implements abstract hooks to react to these lifecycle events +/// and perform app-specific work (loading hierarchy, waveforms, etc.). +abstract class DevToolsConnectionHostState + extends State { + // ══════════════════════════════════════════════════════════════════════════ + // Configuration — override in subclass + // ══════════════════════════════════════════════════════════════════════════ + + /// The connection strategy (platform-specific VM service connection). + /// Return null if VM connection is not supported on this platform. + VmConnectionStrategy? get connectionStrategy; + + // ══════════════════════════════════════════════════════════════════════════ + // Connection state + // ══════════════════════════════════════════════════════════════════════════ + + /// Whether connected to a VM (true after successful handshake). + bool get isConnected => _isConnected; + + /// Sets whether the host is connected to a VM. + @protected + set isConnected(bool value) => _isConnected = value; + bool _isConnected = false; + + /// True while a VM connection handshake is in progress. + bool get isConnecting => _isConnecting; + + /// Sets whether a VM connection handshake is in progress. + @protected + set isConnecting(bool value) => _isConnecting = value; + bool _isConnecting = false; + + /// True when the VM service has been detected as dead. + bool get isVmDead => _isVmDead; + + /// Sets whether the host believes the VM is dead. + @protected + set isVmDead(bool value) => _isVmDead = value; + bool _isVmDead = false; + + /// True when the user deliberately paused the VM connection. + bool get isPaused => _isPaused; + + /// Sets whether the user deliberately paused the VM connection. + @protected + set isPaused(bool value) => _isPaused = value; + bool _isPaused = false; + + /// The active VM service instance (null when disconnected). + VmService? get vmService => _vmService; + + /// Sets the active VM service instance. + @protected + set vmService(VmService? value) => _vmService = value; + VmService? _vmService; + + /// URI of the last/current VM service connection. + String? get lastVmServiceUri => _lastVmServiceUri; + + /// Sets the URI of the last/current VM service connection. + @protected + set lastVmServiceUri(String? value) => _lastVmServiceUri = value; + String? _lastVmServiceUri; + + /// Isolate ID from the last successful connection. + String? get lastIsolateId => _lastIsolateId; + + /// Sets the last known isolate ID. + @protected + set lastIsolateId(String? value) => _lastIsolateId = value; + String? _lastIsolateId; + + /// Name of the connected VM (from DTD discovery). + String? get connectedVmName => _connectedVmName; + + /// Sets the name of the connected VM. + @protected + set connectedVmName(String? value) => _connectedVmName = value; + String? _connectedVmName; + + /// Whether auto-reconnect by name is enabled. + bool get autoReconnect => _autoReconnect; + + /// Sets whether auto-reconnect by name is enabled. + @protected + set autoReconnect(bool value) => _autoReconnect = value; + bool _autoReconnect = false; + + /// Whether a VM service is currently connected (shorthand). + bool get isVmConnected => _vmService != null; + + /// Monotonically increasing counter bumped on every full reconnect. + /// Used for widget keys so Flutter recreates stateful widgets. + int get connectionGeneration => _connectionGeneration; + int _connectionGeneration = 0; + + /// The connection state machine. + ConnectionStateMachine get connectionStateMachine => _csm; + final ConnectionStateMachine _csm = ConnectionStateMachine(); + + /// The persistent DTD connection (for VM lifecycle events + RPC). + DartToolingDaemon? get persistentDtd => _persistentDtd; + DartToolingDaemon? _persistentDtd; + + /// Remembered VM services across reconnects. + List? get rememberedServices => _rememberedServices; + + /// Sets the remembered VM services list. + @protected + set rememberedServices(List? value) => + _rememberedServices = value; + List? _rememberedServices; + + /// Services currently registered on DTD (populated by Service stream). + final Set _availableServices = {}; + + // ── Private connection state ── + + bool _autoReconnectInProgress = false; + int _vmLivenessFailCount = 0; + static const _vmDeadThreshold = 3; + Timer? _vmLivenessTimer; + StreamSubscription? _dtdEventSubscription; + StreamSubscription? _serviceStreamSubscription; + + // ── URI controllers (for connection dialog) ── + + /// Controller for the VM service URI field. + final TextEditingController vmServiceUriController = TextEditingController( + text: 'ws://127.0.0.1:8181/xxxx=/ws', + ); + + /// Controller for the DTD URI field. + final TextEditingController dtdUriController = TextEditingController(); + + /// Most recent connection error shown in the UI. + String? connectionError; + + // ══════════════════════════════════════════════════════════════════════════ + // Abstract hooks — subclass must implement + // ══════════════════════════════════════════════════════════════════════════ + + /// Called after a successful VM connection. + /// + /// [onBeforeVmConnected] runs first, while the previous VM identity is still + /// available for app-specific state preservation decisions. + /// + /// The subclass should create its data sources (tree, waveform, etc.) + /// using the provided [result] and [uri]. The VM service, isolate ID, + /// CSM, liveness timer, and DTD listener are already set up. + Future onVmConnected(VmConnectionResult result, String uri); + + /// Called after a new VM connection is established but before the host + /// updates its own connection identity. + /// + /// [transition] tells the subclass whether this is a fresh attach or a + /// same-VM restart, so app state can be preserved only when appropriate. + Future onBeforeVmConnected( + VmConnectionResult result, + String uri, { + required VmConnectionTransition transition, + }) async {} + + /// Tear down all state from a previous VM connection. + /// + /// Called during disconnect and before reconnect. The subclass should + /// dispose data sources, clear caches, reset cubits, etc. + /// Must be resilient (each step individually guarded). + Future tearDownOldConnection({ + required VmConnectionTransition transition, + }); + + /// Called when a full disconnect completes (before showing dialog). + /// + /// The subclass should clear any UI state and references that are + /// specific to the old connection. + void onVmDisconnected(); + + /// Called when the VM is detected as dead. + void onVmDead() {} + + /// Called when a dead VM recovers (liveness check succeeds). + void onVmRecovered() {} + + /// Called when the host pauses app-specific data fetching. + /// + /// Subclasses can pause waveform or polling work while keeping the VM + /// connection itself alive. + Future onVmPaused() async {} + + /// Called when the host resumes app-specific data fetching. + /// + /// Subclasses can resume waveform or polling work and backfill any + /// missed data while the VM connection remains alive. + Future onVmResumed() async {} + + /// Verify whether a lightweight reconnect is valid. + /// + /// Called with the new [result] after connecting to the same URI. + /// Return true if the isolate matches (same process) and a lightweight + /// swap is appropriate; return false to trigger a full reconnect. + bool onLightweightReconnectCheck(VmConnectionResult result) => + result.isolateId == _lastIsolateId; + + /// Called after a successful lightweight reconnect. + /// + /// The subclass should swap the VM service in existing transports + /// without tearing down tree/schematic/waveform state. + Future onLightweightReconnectSuccess( + VmConnectionResult result, + String uri, + ); + + /// Called when a DTD service becomes available. + /// + /// For example, when the 'rohd' extension service registers on DTD, + /// the subclass can enable source navigation. + void onServiceAvailable(String serviceName) {} + + /// Called when a DTD service becomes unavailable. + void onServiceUnavailable(String serviceName) {} + + @override + + /// Adds the host's public connection state to the diagnostics tree. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(FlagProperty('isConnected', value: isConnected)) + ..add(FlagProperty('isConnecting', value: isConnecting)) + ..add(FlagProperty('isVmDead', value: isVmDead)) + ..add(FlagProperty('isPaused', value: isPaused)) + ..add(DiagnosticsProperty('vmService', vmService)) + ..add(StringProperty('lastVmServiceUri', lastVmServiceUri)) + ..add(StringProperty('lastIsolateId', lastIsolateId)) + ..add(StringProperty('connectedVmName', connectedVmName)) + ..add(FlagProperty('autoReconnect', value: autoReconnect)) + ..add(FlagProperty('isVmConnected', value: isVmConnected)) + ..add(IntProperty('connectionGeneration', connectionGeneration)) + ..add( + DiagnosticsProperty( + 'connectionStrategy', + connectionStrategy, + ), + ) + ..add( + DiagnosticsProperty( + 'connectionStateMachine', + connectionStateMachine, + ), + ) + ..add( + DiagnosticsProperty('persistentDtd', persistentDtd), + ) + ..add( + DiagnosticsProperty?>( + 'rememberedServices', + rememberedServices, + ), + ) + ..add( + DiagnosticsProperty( + 'vmServiceUriController', + vmServiceUriController, + ), + ) + ..add( + DiagnosticsProperty( + 'dtdUriController', + dtdUriController, + ), + ) + ..add(StringProperty('connectionError', connectionError)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ══════════════════════════════════════════════════════════════════════════ + + @override + @mustCallSuper + void initState() { + super.initState(); + _csm.onLoadHierarchy = onCsmLoadHierarchy; + _csm.onStateChange = _onCsmStateChange; + } + + @override + @mustCallSuper + void dispose() { + _vmLivenessTimer?.cancel(); + stopDtdListener(); + unawaited(_csm.dispose()); + vmServiceUriController.dispose(); + dtdUriController.dispose(); + unawaited(_vmService?.dispose()); + super.dispose(); + } + + /// Override in subclass if the CSM's loadHierarchy callback should + /// trigger app-specific loading. Default is a no-op. + Future onCsmLoadHierarchy() async {} + + /// Called by the CSM on state changes. Override for additional behavior. + @protected + void _onCsmStateChange(ConnectionPhase phase, DataLoadState dataState) { + debugPrint('[ConnectionHost] CSM: ${phase.name} $dataState'); + } + + // ══════════════════════════════════════════════════════════════════════════ + // URI Cleaning Utilities + // ══════════════════════════════════════════════════════════════════════════ + + /// Clean a VM service URI by extracting the valid portion. + /// VM URIs start with 'ws:' and end with '=/ws'. + static String cleanVmServiceUri(String input) { + final trimmed = input.trim(); + var startIndex = trimmed.indexOf('ws:'); + if (startIndex < 0) { + startIndex = trimmed.indexOf('wss:'); + } + if (startIndex < 0) { + return trimmed; + } + + const endMarker = '=/ws'; + final endIndex = trimmed.indexOf(endMarker, startIndex); + if (endIndex < 0) { + return trimmed.substring(startIndex); + } + + return trimmed.substring(startIndex, endIndex + endMarker.length); + } + + /// Clean a DTD URI by extracting the valid portion. + /// DTD URIs start with 'ws:' and end with '='. + static String cleanDtdUri(String input) { + final trimmed = input.trim(); + var startIndex = trimmed.indexOf('ws:'); + if (startIndex < 0) { + startIndex = trimmed.indexOf('wss:'); + } + if (startIndex < 0) { + return trimmed; + } + + var searchFrom = startIndex; + while (true) { + final eqIndex = trimmed.indexOf('=', searchFrom); + if (eqIndex < 0) { + return trimmed.substring(startIndex); + } + + if (eqIndex + 3 < trimmed.length && + trimmed.substring(eqIndex, eqIndex + 4) == '=/ws') { + searchFrom = eqIndex + 1; + continue; + } + + return trimmed.substring(startIndex, eqIndex + 1); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Connection Actions (public API for subclass and UI) + // ══════════════════════════════════════════════════════════════════════════ + + /// Connect to a VM service at the given URI. + /// + /// Tears down any previous connection, establishes a new one, starts + /// liveness polling and DTD listener, then calls [onVmConnected]. + Future connectToVmService( + String vmServiceUri, { + VmConnectionTransition transition = const VmConnectionTransition.fresh(), + }) async { + debugPrint('[ConnectionHost] Starting connection to: $vmServiceUri'); + final strategy = connectionStrategy; + if (strategy == null) { + throw Exception('No connection strategy available'); + } + final previousUri = _lastVmServiceUri; + final previousIsolateId = _lastIsolateId; + final effectiveTransition = transition.withPrevious( + previousUri: previousUri, + previousIsolateId: previousIsolateId, + previousVmName: _connectedVmName, + ); + + _csm.handleEvent(ConnectRequested(vmServiceUri)); + + try { + await tearDownOldConnection(transition: effectiveTransition); + } on Exception catch (e) { + debugPrint( + '[ConnectionHost] tearDownOldConnection failed (non-fatal): $e', + ); + _connectionGeneration++; + } + + debugPrint('[ConnectionHost] Calling strategy.connect...'); + final result = await strategy.connect(vmServiceUri); + debugPrint('[ConnectionHost] Connected! isolateId: ${result.isolateId}'); + + await onBeforeVmConnected( + result, + vmServiceUri, + transition: effectiveTransition, + ); + + // Notify the state machine. + final identity = VmIdentity( + uri: vmServiceUri, + isolateId: result.isolateId, + vmName: _connectedVmName, + ); + _csm.handleEvent(ConnectionEstablished(result.vmService, identity)); + + setState(() { + _vmService = result.vmService; + _isConnected = true; + _isConnecting = false; + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + _lastVmServiceUri = vmServiceUri; + _lastIsolateId = result.isolateId; + }); + + // Let the subclass set up its data sources. This is the path that + // calls ServiceManager.vmServiceOpened, which owns streamListen for + // the Debug/Isolate/etc streams. Subscribe to debug events only + // AFTER this has run so we never race ServiceManager. + await onVmConnected(result, vmServiceUri); + unawaited(_csm.subscribeToDebugEvents(result.vmService)); + + // Start VM liveness polling. + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => unawaited(_checkVmLiveness()), + ); + debugPrint('[ConnectionHost] Started VM liveness polling (10 s)'); + + // Start persistent DTD listener. + unawaited(startDtdListener()); + } + + /// Disconnect from the current VM service. + /// + /// Tears down the connection, resets state, and calls [onVmDisconnected]. + Future disconnect() async { + _csm.handleEvent(const DisconnectRequested()); + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + stopDtdListener(); + await tearDownOldConnection( + transition: const VmConnectionTransition.disconnect().withPrevious( + previousUri: _lastVmServiceUri, + previousIsolateId: _lastIsolateId, + previousVmName: _connectedVmName, + ), + ); + + setState(() { + _vmService = null; + _isConnected = false; + _isConnecting = false; + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + connectionError = null; + _lastVmServiceUri = null; + _lastIsolateId = null; + _connectedVmName = null; + _autoReconnect = false; + }); + + onVmDisconnected(); + } + + /// Pause waveform data fetches while keeping VM connection alive. + Future pauseVm() async { + if (!isVmConnected) { + return; + } + debugPrint('[ConnectionHost] Pausing (connection stays alive)'); + _csm.handleEvent(const PauseRequested()); + await onVmPaused(); + setState(() { + _isPaused = true; + }); + } + + /// Resume after a pause. + Future resumeVm() async { + if (!isVmConnected) { + debugPrint('[ConnectionHost] VM not connected — nothing to resume'); + return; + } + debugPrint('[ConnectionHost] Resuming'); + _csm.handleEvent(const ResumeRequested()); + setState(() { + _isPaused = false; + }); + await onVmResumed(); + } + + /// Attempt a lightweight reconnect to the same VM process. + /// + /// Returns true if successful (state preserved), false if the caller + /// should fall through to a full reconnect. + Future lightweightReconnect(String uri) async { + debugPrint('[ConnectionHost] Attempting lightweight reconnect to: $uri'); + final strategy = connectionStrategy; + if (strategy == null) { + return false; + } + + try { + final result = await strategy.connect(uri); + + if (!onLightweightReconnectCheck(result)) { + debugPrint( + '[ConnectionHost] Lightweight check failed — ' + 'need full reconnect', + ); + unawaited(result.vmService.dispose()); + return false; + } + + debugPrint( + '[ConnectionHost] Same process — swapping VM service in-place', + ); + + // Notify the state machine. + final identity = VmIdentity( + uri: uri, + isolateId: result.isolateId, + vmName: _connectedVmName, + ); + _csm.handleEvent(ConnectionEstablished(result.vmService, identity)); + + // Let the subclass swap the transport (this re-runs vmServiceOpened + // on the local ServiceManager, which owns streamListen). Subscribe + // to debug events only AFTER that to avoid racing ServiceManager. + await onLightweightReconnectSuccess(result, uri); + unawaited(_csm.subscribeToDebugEvents(result.vmService)); + + setState(() { + _vmService = result.vmService; + _isConnecting = false; + _isPaused = false; + _isVmDead = false; + _vmLivenessFailCount = 0; + _lastIsolateId = result.isolateId; + }); + + // Restart liveness timer. + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => unawaited(_checkVmLiveness()), + ); + + // Restart DTD listener. + unawaited(startDtdListener()); + + debugPrint('[ConnectionHost] Lightweight reconnect succeeded'); + return true; + } on Exception catch (e) { + debugPrint('[ConnectionHost] Lightweight reconnect failed: $e'); + return false; + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Connection Dialog + // ══════════════════════════════════════════════════════════════════════════ + + /// Show the VM connection dialog. + /// + /// Subclasses can override [buildConnectionDialogContent] to customize. + Future showConnectionDialog() async { + final strategy = connectionStrategy; + if (strategy == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('VM connection not available on this platform'), + ), + ); + } + return; + } + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text('Connect to VM Service'), + content: SizedBox( + width: 400, + child: buildConnectionDialogContent(dialogContext), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ], + ), + ); + } + + /// Build the connection dialog content. + /// + /// Override in subclass to add demo-mode buttons, emoji detection, etc. + @protected + Widget buildConnectionDialogContent(BuildContext dialogContext) => + VmConnectionForm( + vmServiceUriController: vmServiceUriController, + dtdUriController: dtdUriController, + connectionError: connectionError, + onConnect: () async { + try { + await attemptConnection(); + if (mounted && dialogContext.mounted && _isConnected) { + Navigator.of(dialogContext).pop(); + } + } on Exception catch (e) { + setState(() { + connectionError = 'Connection failed: $e'; + }); + } + }, + onDemoMode: () { + Navigator.of(dialogContext).pop(); + onDemoModeRequested(); + }, + showDemoButton: true, + cleanVmServiceUri: cleanVmServiceUri, + cleanDtdUri: cleanDtdUri, + discoverVmServices: discoverVmServices, + initialDiscoveredServices: _rememberedServices + ?.map( + (s) => DiscoveredVmService( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(), + onServicesDiscovered: (services) { + _rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(); + }, + ); + + /// Called when demo mode is selected from the connection dialog. + /// Override in subclass. + @protected + void onDemoModeRequested() {} + + /// Attempt connection using the current URI controller values. + /// + /// If only DTD URI is provided, discovers VMs and picks the first one. + Future attemptConnection() async { + final strategy = connectionStrategy; + if (strategy == null) { + setState(() { + connectionError = 'VM connection not available on this platform'; + }); + return; + } + + try { + final resolution = await resolveVmConnectionAttempt( + rawVmServiceUri: vmServiceUriController.text, + rawDtdUri: dtdUriController.text, + discoverVmServices: discoverVmServices, + ); + + if (resolution.cleanedVmServiceUri != vmServiceUriController.text && + resolution.cleanedVmServiceUri.isNotEmpty) { + vmServiceUriController.text = resolution.cleanedVmServiceUri; + } + if (resolution.cleanedDtdUri != dtdUriController.text && + resolution.cleanedDtdUri.isNotEmpty) { + dtdUriController.text = resolution.cleanedDtdUri; + } + if (resolution.error != null) { + setState(() { + connectionError = resolution.error; + }); + return; + } + final vmServiceUri = resolution.vmServiceUri!; + + setState(() { + connectionError = null; + }); + + setState(() { + _isConnecting = true; + }); + + // Capture VM name and auto-reconnect from discovery list. + final matchedService = + _rememberedServices?.cast().firstWhere( + (s) => s!.connectionUri == vmServiceUri, + orElse: () => null, + ); + _connectedVmName = matchedService?.name; + _autoReconnect = matchedService?.autoReconnect ?? false; + + await connectToVmService(vmServiceUri); + } on Exception catch (e) { + debugPrint( + '[ConnectionHost] attemptConnection failed: ' + '${e.runtimeType}: $e', + ); + if (mounted) { + setState(() { + _isConnected = false; + _isConnecting = false; + connectionError = 'Connection failed: $e'; + }); + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // DTD Discovery + // ══════════════════════════════════════════════════════════════════════════ + + /// Discover VM services from a DTD URI. + /// + /// Connects to DTD, calls getVmServices(), returns the list. + /// Also probes for registered services (new DTD 4.0 API). + Future> discoverVmServices(String dtdUri) async => + discoverVmServicesViaDtd( + dtdUri, + onRegisteredServices: (serviceNames) { + _availableServices + ..clear() + ..addAll(serviceNames); + }, + ); + + /// Check whether a named service is currently available on DTD. + bool isServiceAvailable(String serviceName) => + _availableServices.contains(serviceName); + + // ══════════════════════════════════════════════════════════════════════════ + // DTD Persistent Listener + // ══════════════════════════════════════════════════════════════════════════ + + /// Start the persistent DTD connection for VM lifecycle events. + @protected + Future startDtdListener() async { + final raw = dtdUriController.text; + if (raw.isEmpty) { + return; + } + + // Don't restart if already listening. + if (_persistentDtd != null && !_persistentDtd!.isClosed) { + return; + } + + try { + final dtd = await DartToolingDaemon.connect(Uri.parse(raw)); + _persistentDtd = dtd; + + // Notify subclass that DTD is available. + onDtdConnected(dtd); + + // Listen for VM service register/unregister events. + _dtdEventSubscription = dtd.onVmServiceUpdate().listen( + handleDtdVmEvent, + onError: (Object e) { + debugPrint('[ConnectionHost] DTD event stream error: $e'); + }, + onDone: () { + debugPrint('[ConnectionHost] DTD event stream closed'); + _persistentDtd = null; + _dtdEventSubscription = null; + onDtdDisconnected(); + }, + ); + + await dtd.streamListen(ConnectedAppServiceConstants.serviceName); + debugPrint('[ConnectionHost] Listening for VM lifecycle events'); + + // Subscribe to Service stream for extension availability. + try { + _serviceStreamSubscription = dtd + .onEvent(CoreDtdServiceConstants.servicesStreamId) + .listen(_handleServiceStreamEvent); + await dtd.streamListen(CoreDtdServiceConstants.servicesStreamId); + debugPrint('[ConnectionHost] Listening for Service stream events'); + } on Exception catch (e) { + debugPrint('[ConnectionHost] Service stream subscription failed: $e'); + } + + // Probe registered services on initial connect. + try { + final registered = await dtd.getRegisteredServices(); + _availableServices.clear(); + for (final svc in registered.clientServices) { + _availableServices.add(svc.name); + onServiceAvailable(svc.name); + } + } on Exception catch (e) { + debugPrint('[ConnectionHost] getRegisteredServices failed: $e'); + } + + // Use dtd.done as a backup death detector. + unawaited( + dtd.done.then((_) { + if (_persistentDtd == dtd) { + debugPrint('[ConnectionHost] dtd.done fired — DTD connection lost'); + _persistentDtd = null; + unawaited(_dtdEventSubscription?.cancel()); + _dtdEventSubscription = null; + unawaited(_serviceStreamSubscription?.cancel()); + _serviceStreamSubscription = null; + onDtdDisconnected(); + } + }), + ); + } on Exception catch (e) { + debugPrint('[ConnectionHost] Could not start DTD listener: $e'); + } + } + + /// Stop the persistent DTD listener. + @protected + void stopDtdListener() { + unawaited(_dtdEventSubscription?.cancel()); + _dtdEventSubscription = null; + unawaited(_serviceStreamSubscription?.cancel()); + _serviceStreamSubscription = null; + if (_persistentDtd != null && !_persistentDtd!.isClosed) { + unawaited(_persistentDtd!.close()); + } + _persistentDtd = null; + onDtdDisconnected(); + } + + /// Called when the persistent DTD connection is established. + /// Override to wire DTD to source navigation, etc. + void onDtdConnected(DartToolingDaemon dtd) {} + + /// Called when the persistent DTD connection is lost. + @protected + void onDtdDisconnected() {} + + /// Handle Service stream events (extension registered/unregistered). + void _handleServiceStreamEvent(DTDEvent event) { + final kind = event.kind; + // The service name is in event.data under 'service' or 'method'. + final serviceName = event.data['service']?.toString(); + if (serviceName == null || serviceName.isEmpty) { + return; + } + + if (kind == CoreDtdServiceConstants.serviceRegisteredKind) { + if (_availableServices.add(serviceName)) { + debugPrint('[ConnectionHost] Service available: $serviceName'); + onServiceAvailable(serviceName); + } + } else if (kind == CoreDtdServiceConstants.serviceUnregisteredKind) { + if (_availableServices.remove(serviceName)) { + debugPrint('[ConnectionHost] Service unavailable: $serviceName'); + onServiceUnavailable(serviceName); + } + } + } + + /// Handle DTD VM lifecycle events. + /// + /// When a VM service is unregistered, marks the connection as dead. + /// When a new VM with our name registers, triggers auto-reconnect. + @protected + Future handleDtdVmEvent(DTDEvent event) async { + debugPrint('[ConnectionHost] DTD event: ${event.kind} — ${event.data}'); + + // Ignore events while manually paused (except vmServiceRegistered). + if (_isPaused && + event.kind != ConnectedAppServiceConstants.vmServiceRegistered) { + debugPrint('[ConnectionHost] Ignoring event — VM is manually paused'); + return; + } + + // Ignore events while a connection is in progress. + if (_isConnecting) { + debugPrint('[ConnectionHost] Ignoring event — connection in progress'); + return; + } + + if (event.kind == ConnectedAppServiceConstants.vmServiceUnregistered) { + final eventUri = event.data[DtdParameters.uri]?.toString(); + final eventExposedUri = event.data[DtdParameters.exposedUri]?.toString(); + final ourUri = _lastVmServiceUri; + + if (!dtdEventMatchesTrackedVm( + trackedVmUri: ourUri, + eventUri: eventUri, + eventExposedUri: eventExposedUri, + )) { + debugPrint( + '[ConnectionHost] Ignoring unregister for different VM: ' + 'uri=$eventUri, exposedUri=$eventExposedUri (ours: $ourUri)', + ); + return; + } + + debugPrint( + '[ConnectionHost] Our VM service was unregistered — marking dead', + ); + _csm.handleEvent(const DtdVmUnregistered()); + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + + if (mounted) { + setState(() { + _isVmDead = true; + }); + onVmDead(); + } + } else if (event.kind == ConnectedAppServiceConstants.vmServiceRegistered) { + if (!_autoReconnect || !mounted) { + return; + } + + final eventName = event.data[DtdParameters.name]?.toString(); + + if (eventName == null || + eventName.isEmpty || + eventName != _connectedVmName) { + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" — ' + 'not our target "$_connectedVmName", ignoring', + ); + return; + } + + final newUri = preferredVmServiceUriFromDtdEvent(event); + if (newUri == null || newUri.isEmpty) { + debugPrint( + '[ConnectionHost] vmServiceRegistered — no URI in event data', + ); + return; + } + + if (_isVmDead || _isPaused) { + _csm.handleEvent(DtdVmRegistered(newUri, name: eventName)); + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" at ' + '$newUri — auto-reconnecting', + ); + unawaited(reconnectFromDtdEvent(newUri)); + } else if (_isConnected) { + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" at ' + '$newUri — reconnecting (sameUri=${newUri == _lastVmServiceUri})', + ); + setState(() { + _isVmDead = true; + }); + _csm.handleEvent(DtdVmRegistered(newUri, name: eventName)); + unawaited(reconnectFromDtdEvent(newUri)); + } + } + } + + /// Reconnect driven by a DTD vmServiceRegistered event. + @protected + Future reconnectFromDtdEvent(String newUri) async { + if (_autoReconnectInProgress) { + debugPrint('[ConnectionHost] Already reconnecting — skipping'); + return; + } + _autoReconnectInProgress = true; + + final wasPaused = _isPaused; + if (wasPaused) { + debugPrint('[ConnectionHost] Clearing stale pause state'); + } + + try { + final sameUri = newUri == _lastVmServiceUri; + + if (sameUri) { + debugPrint('[ConnectionHost] Same URI — trying lightweight reconnect'); + stopDtdListener(); + final success = await lightweightReconnect(newUri); + if (success) { + debugPrint('[ConnectionHost] Lightweight reconnect succeeded'); + _autoReconnectInProgress = false; + return; + } + debugPrint('[ConnectionHost] Lightweight failed — full reconnect'); + } + + await performFullReconnect(newUri); + } on Exception catch (e) { + debugPrint('[ConnectionHost] DTD reconnect failed: $e'); + if (_autoReconnect && _isVmDead && mounted) { + unawaited(attemptAutoReconnect()); + } + } finally { + _autoReconnectInProgress = false; + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // VM Liveness Polling + // ══════════════════════════════════════════════════════════════════════════ + + /// Check if the VM service is alive (getVersion with timeout). + Future isVmServiceAlive() async { + final vm = _vmService; + if (vm == null) { + return false; + } + try { + await vm.getVersion().timeout(const Duration(seconds: 5)); + return true; + } on Exception { + return false; + } + } + + /// Periodic liveness probe. + Future _checkVmLiveness() async { + if (!isVmConnected || !mounted || _isPaused || _isConnecting) { + return; + } + final alive = await isVmServiceAlive(); + + if (!mounted || _isPaused || _isConnecting || !isVmConnected) { + return; + } + + if (alive) { + _vmLivenessFailCount = 0; + if (_isVmDead && mounted) { + debugPrint('[ConnectionHost] VM recovered — clearing dead flag'); + _csm.handleEvent(const VmRecovered()); + setState(() { + _isVmDead = false; + }); + onVmRecovered(); + } + } else { + _vmLivenessFailCount++; + debugPrint( + '[ConnectionHost] VM check failed ' + '($_vmLivenessFailCount/$_vmDeadThreshold)', + ); + if (_vmLivenessFailCount >= _vmDeadThreshold && !_isVmDead && mounted) { + debugPrint('[ConnectionHost] VM is dead'); + _csm.handleEvent(const VmDied()); + setState(() { + _isVmDead = true; + }); + onVmDead(); + if (_autoReconnect) { + unawaited(attemptAutoReconnect()); + } + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Auto-Reconnect + // ══════════════════════════════════════════════════════════════════════════ + + /// Attempt to reconnect to a VM with the same name (exponential backoff). + Future attemptAutoReconnect() async { + if (_autoReconnectInProgress) { + debugPrint('[ConnectionHost] Already reconnecting — skipping'); + return; + } + _autoReconnectInProgress = true; + + final targetName = _connectedVmName; + final dtdUri = dtdUriController.text; + if (targetName == null || targetName.isEmpty || dtdUri.isEmpty) { + debugPrint('[ConnectionHost] No VM name or DTD URI — skipping'); + _autoReconnectInProgress = false; + return; + } + + debugPrint('[ConnectionHost] Will try to reconnect to "$targetName"'); + + const maxAttempts = 5; + var delay = const Duration(seconds: 2); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + await Future.delayed(delay); + if (!mounted || !_isVmDead || !_autoReconnect) { + _autoReconnectInProgress = false; + return; + } + + debugPrint( + '[ConnectionHost] Auto-reconnect attempt ' + '$attempt/$maxAttempts', + ); + try { + final cleaned = cleanDtdUri(dtdUri); + final services = await discoverVmServices(cleaned); + final match = services.cast().firstWhere( + (s) => s!.name == targetName, + orElse: () => null, + ); + + if (match != null) { + final sameUri = match.connectionUri == _lastVmServiceUri; + debugPrint( + '[ConnectionHost] Found "$targetName" at ' + '${match.connectionUri} ' + '(${sameUri ? "same" : "different"} URI)', + ); + + _rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + autoReconnect: s.connectionUri == match.connectionUri, + ), + ) + .toList(); + + if (sameUri) { + stopDtdListener(); + final success = await lightweightReconnect(match.connectionUri); + if (success) { + debugPrint( + '[ConnectionHost] Auto lightweight reconnect succeeded', + ); + _autoReconnectInProgress = false; + return; + } + } + + await performFullReconnect(match.connectionUri); + + if (isVmConnected && !_isVmDead) { + debugPrint('[ConnectionHost] Auto-reconnected to "$targetName"'); + _autoReconnectInProgress = false; + return; + } + } else { + debugPrint('[ConnectionHost] "$targetName" not found — will retry'); + } + } on Exception catch (e) { + debugPrint('[ConnectionHost] Attempt $attempt failed: $e'); + } + + delay *= 2; + } + + debugPrint('[ConnectionHost] Gave up after $maxAttempts attempts'); + _autoReconnectInProgress = false; + } + + /// Perform a full reconnect to [newUri], preserving same-VM restart + /// semantics for subclass hooks. + @protected + Future performFullReconnect(String newUri) async { + final reconnectState = _prepareForFullReconnect(newUri); + + await connectToVmService( + newUri, + transition: const VmConnectionTransition.sameVmRestart(), + ); + if (mounted) { + setState(() {}); + } + + _restoreReconnectPreferences(reconnectState); + } + + _ReconnectState _prepareForFullReconnect(String newUri) { + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + + vmServiceUriController.text = newUri; + + final reconnectState = _ReconnectState( + connectedVmName: _connectedVmName, + autoReconnect: _autoReconnect, + ); + + stopDtdListener(); + setState(() { + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + }); + + return reconnectState; + } + + void _restoreReconnectPreferences(_ReconnectState reconnectState) { + _connectedVmName = reconnectState.connectedVmName; + _autoReconnect = reconnectState.autoReconnect; + } + + /// Increment the connection generation (triggers widget recreation). + @protected + void bumpConnectionGeneration() { + _connectionGeneration++; + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart new file mode 100644 index 000000000..4281686f6 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_help_button.dart +// Help button widget for the ROHD DevTools app bar. +// +// Content is loaded from assets/help/devtools_help.md. +// Edit that markdown file to update hover tooltip and dialog content. +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the ROHD DevTools app bar. +/// +/// Content is driven by `assets/help/devtools_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DevToolsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DevToolsHelpButton]. + const DevToolsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/devtools_help.md', + isDark: isDark, + labelIcon: kIsWeb ? const Icon(Icons.help_outline, size: 20) : null, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('isDark', value: isDark)); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart index 40f1e72de..9cf9e0741 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // module_tree_card.dart @@ -7,127 +7,125 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_simple_treeview/flutter_simple_treeview.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/selected_module_cubit.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/tree_search_term_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/services/tree_service.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +/// Displays the module tree for the currently loaded ROHD model. class ModuleTreeCard extends StatefulWidget { + /// The root module to render as the tree. final TreeModel futureModuleTree; - const ModuleTreeCard({ - super.key, - required this.futureModuleTree, - }); + + /// Creates a module tree card for the provided module tree. + const ModuleTreeCard({required this.futureModuleTree, super.key}); @override + + /// Creates the mutable state for [ModuleTreeCard]. State createState() => _ModuleTreeCardState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('futureModuleTree', futureModuleTree)); + } } class _ModuleTreeCardState extends State { + /// Creates the module tree card state. _ModuleTreeCardState(); @override - Widget build(BuildContext context) { - return genModuleTree( - moduleTree: widget.futureModuleTree, - ); - } + /// Builds the module tree widget. + Widget build(BuildContext context) => + genModuleTree(moduleTree: widget.futureModuleTree); + + /// Builds a tree node for [module], returning null if it is filtered out. TreeNode? buildNode(TreeModel module) { final treeSearchTerm = context.watch().state; - // If there's a search term, ensure that either this node or a descendant node matches it. + // If there's a search term, ensure that either this node or a + // descendant node matches it. if (treeSearchTerm != null && !TreeService.isNodeOrDescendentMatching(module, treeSearchTerm)) { return null; } // Build children recursively - List childrenNodes = buildChildrenNodes(module); + final childrenNodes = buildChildrenNodes(module); return TreeNode( - content: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - context.read().setModule(module); - }, - child: getNodeContent(module), - ), - ), - children: childrenNodes, - ); + content: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + context.read().setModule(module); + }, + child: getNodeContent(module))), + children: childrenNodes); } + /// Builds the visible text and icon for a tree node. Widget getNodeContent(TreeModel module) { final selectedModule = context.watch().state; + final colorScheme = Theme.of(context).colorScheme; // Check if the current module is the selected module - bool isSelected = selectedModule is SelectedModuleLoaded && + final isSelected = selectedModule is SelectedModuleLoaded && selectedModule.module == module; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( decoration: BoxDecoration( - color: - isSelected ? Colors.blue.withOpacity(0.2) : Colors.transparent, - borderRadius: BorderRadius.circular(4.0), - ), - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - child: Row( - children: [ - const Icon(Icons.memory), - const SizedBox(width: 2.0), - Text( - module.name, + color: isSelected + ? Colors.blue.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row(children: [ + Icon(Icons.memory, color: colorScheme.onSurface), + const SizedBox(width: 2), + Text(module.name, style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? Colors.blue : Colors.white, - ), - ), - ], - ), - ), - ], - ); + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface)) + ])) + ]); } - List buildChildrenNodes( - TreeModel treeModule, - ) { - List childrenNodes = []; - List subModules = treeModule.subModules; + /// Builds child tree nodes for the given module. + List buildChildrenNodes(TreeModel treeModule) { + final childrenNodes = []; + final subModules = treeModule.subModules; if (subModules.isNotEmpty) { - for (var module in subModules) { - TreeNode? node = buildNode(module); + for (final module in subModules) { + final node = buildNode(module); if (node != null) { childrenNodes.add(node); } } } - return childrenNodes - .where((node) => node != null) - .toList() - .cast(); + return childrenNodes; } - TreeNode? buildTreeFromModule(TreeModel node) { - return buildNode(node); - } + /// Returns a tree node wrapper for the provided module. + TreeNode? buildTreeFromModule(TreeModel node) => buildNode(node); - Widget genModuleTree({ - required TreeModel moduleTree, - }) { - var root = buildNode(moduleTree); + /// Builds the full tree view widget for [moduleTree]. + Widget genModuleTree({required TreeModel moduleTree}) { + final root = buildNode(moduleTree); if (root != null) { return TreeView(nodes: [root]); - } else { - return const Text('No data'); } + return const Text('No data'); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart index f84835e5e..d8e472006 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // module_tree_details_navbar.dart @@ -7,39 +7,128 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/details_help_button.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/schematic_icon.dart'; +/// Navigation bar for switching between module detail views. class ModuleTreeDetailsNavbar extends StatelessWidget { - const ModuleTreeDetailsNavbar({ - super.key, - }); + /// Whether color emoji fonts are available on this platform. + final bool hasColorEmoji; + + /// Creates the details navigation bar. + const ModuleTreeDetailsNavbar({super.key, this.hasColorEmoji = kIsWeb}); + + @override + + /// Adds diagnostic properties for the nav bar. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('hasColorEmoji', + value: hasColorEmoji, ifFalse: 'using fallback emojis')); + } @override + + /// Builds the tab row and help button for module details. Widget build(BuildContext context) { - return BottomNavigationBar( - type: BottomNavigationBarType.fixed, - backgroundColor: const Color(0x1B1B1FEE), - selectedItemColor: Colors.white, - unselectedItemColor: Colors.white.withOpacity(.60), - selectedFontSize: 10, - unselectedFontSize: 10, - onTap: (value) { - // Respond to item press. - }, - items: const [ - BottomNavigationBarItem( - label: 'Details', - icon: Icon(Icons.info), - ), - BottomNavigationBarItem( - label: 'Waveform', - icon: Icon(Icons.cable), - ), - BottomNavigationBarItem( - label: 'Schematic', - icon: Icon(Icons.developer_board), - ), - ], - ); + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor))), + child: BlocBuilder( + builder: (context, selectedTab) => Row(children: [ + _TabButton( + label: 'Details', + icon: platformIcon(Icons.info, 'ℹ️', + size: 18, hasColorEmoji: hasColorEmoji), + isSelected: selectedTab == DetailsTab.details, + onTap: () => context + .read() + .selectTab(DetailsTab.details)), + _TabButton( + label: 'Waveform', + icon: platformIcon(Icons.waves, '🌊', + size: 18, hasColorEmoji: hasColorEmoji), + isSelected: selectedTab == DetailsTab.waveform, + onTap: () => context + .read() + .selectTab(DetailsTab.waveform)), + _TabButton( + label: 'Schematic', + icon: const SchematicIcon(size: 18), + isSelected: selectedTab == DetailsTab.schematic, + onTap: () => context + .read() + .selectTab(DetailsTab.schematic)), + const Spacer(), + DetailsHelpButton(isDark: isDark) + ]))); + } +} + +class _TabButton extends StatelessWidget { + /// The tab text label. + final String label; + + /// Icon shown next to the label. + final Widget icon; + + /// Whether this tab is currently selected. + final bool isSelected; + + /// Callback invoked when the tab is tapped. + final VoidCallback onTap; + + const _TabButton( + {required this.label, + required this.icon, + required this.isSelected, + required this.onTap}); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(FlagProperty('isSelected', value: isSelected)) + ..add( + ObjectFlagProperty('onTap', onTap, ifNull: 'disabled')); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final selectedColor = colorScheme.primary; + final unselectedColor = colorScheme.onSurface.withValues(alpha: 0.6); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? selectedColor : Colors.transparent, + width: 2))), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + icon, + const SizedBox(width: 8), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? selectedColor : unselectedColor)) + ]))); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart new file mode 100644 index 000000000..821c515d9 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart @@ -0,0 +1,118 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_icon.dart +// Provides platform-aware icon rendering with emoji fallback. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A widget that renders either a Material Icon or emoji text based on +/// platform emoji font availability. +/// +/// On platforms with color emoji support, uses the provided emoji string. +/// On platforms without (or with `hasColorEmoji: false`), falls back to +/// the Material IconData. +class PlatformIcon extends StatelessWidget { + /// Material IconData to use as fallback on platforms without color emoji + final IconData nativeIcon; + + /// Emoji string to display if color emoji fonts are available + final String emoji; + + /// Size of the icon/emoji (defaults to 16) + final double? size; + + /// Color to apply to the icon/emoji + final Color? color; + + /// Whether color emoji fonts are available on this platform + /// (defaults to true - verify on native platforms) + final bool hasColorEmoji; + + /// Constructor for [PlatformIcon]. + const PlatformIcon( + this.nativeIcon, + this.emoji, { + this.size, + this.color, + this.hasColorEmoji = true, + super.key, + }); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('nativeIcon', nativeIcon)) + ..add(StringProperty('emoji', emoji)) + ..add(DoubleProperty('size', size)) + ..add(ColorProperty('color', color)) + ..add( + FlagProperty( + 'hasColorEmoji', + value: hasColorEmoji, + ifFalse: 'using fallback icons', + ), + ); + } + + @override + Widget build(BuildContext context) { + if (hasColorEmoji) { + return Text( + emoji, + style: TextStyle(fontSize: size ?? 16, color: color), + ); + } + return Icon(nativeIcon, size: size, color: color); + } +} + +/// Helper function for quick construction of PlatformIcon widgets. +/// +/// Returns a PlatformIcon widget that renders either emoji or Material icon +/// based on platform capabilities. +/// +/// Example: +/// ```dart +/// platformIcon(Icons.waves, '🔗', size: 24, hasColorEmoji: true) +/// ``` +Widget platformIcon( + IconData nativeIcon, + String emoji, { + double? size, + Color? color, + bool hasColorEmoji = true, +}) => + PlatformIcon( + nativeIcon, + emoji, + size: size, + color: color, + hasColorEmoji: hasColorEmoji, + ); + +/// Check whether a color emoji font (Noto Color Emoji) is installed on the +/// system. Returns true on web (always has emoji), or checks fc-list on Linux. +Future isEmojiFontInstalled() async { + if (kIsWeb) { + return true; // Web always has color emoji + } + + try { + final result = await Process.run('fc-list', []); + if (result.exitCode == 0) { + final out = result.stdout.toString().toLowerCase(); + return out.contains('noto color emoji'); + } + } on Exception { + // fc-list command not available or failed; assume no emoji font + } + return false; +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart new file mode 100644 index 000000000..b8a5c69df --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// schematic_icon.dart +// Custom icon: three colored blocks connected by orthogonal lines, +// resembling a small schematic / block diagram. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A custom-painted icon showing three colored rectangles connected +/// by orthogonal (right-angle) wires — a miniature schematic diagram. +class SchematicIcon extends StatelessWidget { + /// Creates a schematic icon at the given [size]. + const SchematicIcon({super.key, this.size = 20, this.brightness}); + + /// Icon size in logical pixels (width = height). + final double size; + + /// Override brightness to force light/dark wire color. + /// If null, uses the ambient [Theme.of(context).brightness]. + final Brightness? brightness; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('size', size)) + ..add(EnumProperty('brightness', brightness)); + } + + @override + + /// Builds the custom-painted schematic icon. + Widget build(BuildContext context) { + final effectiveBrightness = brightness ?? Theme.of(context).brightness; + return CustomPaint( + size: Size.square(size), + painter: _SchematicIconPainter(effectiveBrightness), + ); + } +} + +class _SchematicIconPainter extends CustomPainter { + _SchematicIconPainter(this.brightness); + + final Brightness brightness; + + @override + + /// Paints the schematic-style icon. + void paint(Canvas canvas, Size size) { + final s = size.width; + + final bw = s * 0.30; + final bh = s * 0.22; + final r = s * 0.04; + + final ax = s * 0.02; + final ay = s * 0.08; + final bx = s * 0.02; + final by = s * 0.62; + final cx = s * 0.64; + final cy = s * 0.38; + + final wireColor = + brightness == Brightness.dark ? Colors.white70 : Colors.black54; + final wirePaint = Paint() + ..color = wireColor + ..strokeWidth = s * 0.045 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final jx = s * 0.52; + final aPortY = ay + bh / 2; + final bPortY = by + bh / 2; + final cPortY = cy + bh / 2; + + final wireA = Path() + ..moveTo(ax + bw, aPortY) + ..lineTo(jx, aPortY) + ..lineTo(jx, cPortY); + canvas.drawPath(wireA, wirePaint); + + final wireB = Path() + ..moveTo(bx + bw, bPortY) + ..lineTo(jx, bPortY) + ..lineTo(jx, cPortY); + canvas.drawPath(wireB, wirePaint); + + final wireC = Path() + ..moveTo(jx, cPortY) + ..lineTo(cx, cPortY); + canvas.drawPath(wireC, wirePaint); + + final dotPaint = Paint()..color = wireColor; + canvas.drawCircle(Offset(jx, cPortY), s * 0.04, dotPaint); + + const colorA = Color(0xFF4A90D9); + const colorB = Color(0xFF50B86C); + const colorC = Color(0xFFE8943A); + + void drawBlock(double x, double y, Color color) { + final rect = RRect.fromLTRBR(x, y, x + bw, y + bh, Radius.circular(r)); + final fill = Paint()..color = color; + canvas.drawRRect(rect, fill); + final border = Paint() + ..color = color.withAlpha(200) + ..style = PaintingStyle.stroke + ..strokeWidth = s * 0.02; + canvas.drawRRect(rect, border); + } + + drawBlock(ax, ay, colorA); + drawBlock(bx, by, colorB); + drawBlock(cx, cy, colorC); + } + + @override + bool shouldRepaint(_SchematicIconPainter old) => old.brightness != brightness; +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart index 0d3fdeb3a..c143059dc 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // signal_details_card.dart @@ -7,123 +7,175 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; - -import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table_text_field.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/details_help_button.dart'; import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table_text_field.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/simulation_time_display.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; +/// Shows the selected module's signal details and search controls. class SignalDetailsCard extends StatefulWidget { + /// The module currently selected for inspection. final TreeModel? module; + /// Optional snapshot data to overlay signal values. + final SnapshotLoaded? snapshot; + + /// Display settings for simulation time values. + final SimulationTimeDisplay timeDisplay; + + /// Creates a signal details card for the selected module. const SignalDetailsCard({ - Key? key, + super.key, this.module, - }) : super(key: key); + this.snapshot, + this.timeDisplay = SimulationTimeDisplay.none, + }); @override + + /// Creates the mutable state for [SignalDetailsCard]. SignalDetailsCardState createState() => SignalDetailsCardState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('module', module)) + ..add(DiagnosticsProperty('snapshot', snapshot)) + ..add(DiagnosticsProperty( + 'timeDisplay', timeDisplay)); + } } +/// State for [SignalDetailsCard]. class SignalDetailsCardState extends State { + /// Search term used to filter signals. String? searchTerm; + + /// Whether input signals are shown. ValueNotifier inputSelected = ValueNotifier(true); + + /// Whether output signals are shown. ValueNotifier outputSelected = ValueNotifier(true); + + /// Whether inout signals are shown. + ValueNotifier inoutSelected = ValueNotifier(true); + + /// Notifies the widget tree to rebuild after filter changes. ValueNotifier notifier = ValueNotifier(0); - void toggleNotifier() { - notifier.value++; - } + /// Boundary used when exporting the signal details panel as PNG. + final GlobalKey _boundaryKey = GlobalKey(); + + /// Increments the rebuild notifier. + void toggleNotifier() => notifier.value++; void _showFilterDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return AlertDialog( - title: const Text('Filter Signals'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ + unawaited(showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Filter Signals'), + content: + Column(mainAxisSize: MainAxisSize.min, children: [ + CheckboxListTile( + title: const Text('Input'), + value: inputSelected.value, + onChanged: (value) { + setState(() { + inputSelected.value = value!; + }); + toggleNotifier(); + }), CheckboxListTile( - title: const Text('Input'), - value: inputSelected.value, - onChanged: (bool? value) { - setState(() { - inputSelected.value = value!; - }); - toggleNotifier(); - }, - ), + title: const Text('Output'), + value: outputSelected.value, + onChanged: (value) { + setState(() { + outputSelected.value = value!; + }); + toggleNotifier(); + }), CheckboxListTile( - title: const Text('Output'), - value: outputSelected.value, - onChanged: (bool? value) { - setState(() { - outputSelected.value = value!; - }); - toggleNotifier(); - }, - ), - ], - ), - ); - }, - ); - }, - ); + title: const Text('Inout'), + value: inoutSelected.value, + onChanged: (value) { + setState(() { + inoutSelected.value = value!; + }); + toggleNotifier(); + }) + ]))))); } @override + + /// Builds the signal details panel for the selected module. Widget build(BuildContext context) { if (widget.module == null) { return const Padding( - padding: EdgeInsets.only(top: 20.0), - child: Center(child: Text('No module selected')), - ); + padding: EdgeInsets.only(top: 20), + child: Center(child: Text('No module selected'))); } - return SizedBox( - height: MediaQuery.of(context).size.height / 1.4, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Column( - children: [ + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Stack(fit: StackFit.expand, children: [ + RepaintBoundary( + key: _boundaryKey, + child: SingleChildScrollView( + child: Column(children: [ Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ + padding: const EdgeInsets.all(8), + child: Row(children: [ SignalTableTextField( - labelText: 'Search Signals', - onChanged: (value) { - setState(() { - searchTerm = value; - }); - toggleNotifier(); - }, - ), + labelText: 'Search Signals', + onChanged: (value) { + setState(() { + searchTerm = value; + }); + toggleNotifier(); + }), IconButton( - icon: const Icon(Icons.filter_list), - onPressed: _showFilterDialog, - ), - ], - ), - ), + icon: const Icon(Icons.filter_list), + onPressed: _showFilterDialog), + DetailsHelpButton(isDark: isDark) + ])), ValueListenableBuilder( - valueListenable: notifier, - builder: (context, _, __) { - return SignalTable( - selectedModule: widget.module!, - searchTerm: searchTerm, - inputSelectedVal: inputSelected.value, - outputSelectedVal: outputSelected.value, - ); - }, - ), - ], - ), - ), - ); + valueListenable: notifier, + builder: (context, _, __) => SignalTable( + selectedModule: widget.module!, + searchTerm: searchTerm, + inputSelectedVal: inputSelected.value, + outputSelectedVal: outputSelected.value, + inoutSelectedVal: inoutSelected.value, + snapshot: widget.snapshot, + timeDisplay: widget.timeDisplay)) + ]))), + Positioned( + right: 8, + bottom: 8, + child: ExportPngButton( + onPressed: () => captureBoundaryToPng(context, + boundaryKey: _boundaryKey, filePrefix: 'signal_details'))) + ]); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('searchTerm', searchTerm)) + ..add(FlagProperty('inputSelected', value: inputSelected.value)) + ..add(FlagProperty('outputSelected', value: outputSelected.value)) + ..add(FlagProperty('inoutSelected', value: inoutSelected.value)) + ..add(IntProperty('notifier', notifier.value)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart index 8e97328d8..8c341c87f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart @@ -7,129 +7,162 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/services/signal_service.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/simulation_time_display.dart'; +/// Displays the signals for a selected module in a table. class SignalTable extends StatefulWidget { + /// The module whose signals are shown in the table. final TreeModel selectedModule; + + /// Optional search term used to filter visible signals. final String? searchTerm; + + /// Whether input signals should be shown. final bool inputSelectedVal; + + /// Whether output signals should be shown. final bool outputSelectedVal; - const SignalTable({ - super.key, - required this.selectedModule, - required this.searchTerm, - required this.inputSelectedVal, - required this.outputSelectedVal, - }); + + /// Whether inout signals should be shown. + final bool inoutSelectedVal; + + /// Optional snapshot data to overlay signal values. + final SnapshotLoaded? snapshot; + + /// Display settings for simulation time values. + final SimulationTimeDisplay timeDisplay; + + /// Creates a signal table for the given module and filters. + const SignalTable( + {required this.selectedModule, + required this.searchTerm, + required this.inputSelectedVal, + required this.outputSelectedVal, + required this.inoutSelectedVal, + this.snapshot, + this.timeDisplay = SimulationTimeDisplay.none, + super.key}); @override - State createState() => _SignalTableState(); + + /// Creates the state object for [SignalTable]. + State createState() => _SignalTableState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedModule', selectedModule)) + ..add(StringProperty('searchTerm', searchTerm)) + ..add(FlagProperty('inputSelectedVal', value: inputSelectedVal)) + ..add(FlagProperty('outputSelectedVal', value: outputSelectedVal)) + ..add(FlagProperty('inoutSelectedVal', value: inoutSelectedVal)) + ..add(DiagnosticsProperty('snapshot', snapshot)) + ..add(DiagnosticsProperty( + 'timeDisplay', timeDisplay)); + } } class _SignalTableState extends State { @override + + /// Builds the signal table and its rows. Widget build(BuildContext context) { - final tableHeaders = ['Name', 'Direction', 'Value', 'Width']; + final snapshotTime = widget.snapshot?.time; + final valueHeader = snapshotTime != null + ? 'Value (@ ${widget.timeDisplay.format(snapshotTime)})' + : 'Value'; + final tableHeaders = ['Name', 'Direction', valueHeader, 'Width']; return Table( - border: TableBorder.all(), - columnWidths: const { - 0: FlexColumnWidth(), - 1: FlexColumnWidth(), - 2: FlexColumnWidth(), - }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow( - children: List.generate( - tableHeaders.length, - (index) => _buildTableHeader(text: tableHeaders[index]), - ), - ), - ...generateSignalsRow( - widget.selectedModule, - widget.searchTerm, - widget.inputSelectedVal, - widget.outputSelectedVal, - ), - ], - ); + border: TableBorder.all(), + columnWidths: const { + 0: FlexColumnWidth(), + 1: FlexColumnWidth(), + 2: FlexColumnWidth() + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: List.generate(tableHeaders.length, + (index) => _buildTableHeader(text: tableHeaders[index]))), + ...generateSignalsRow(widget.selectedModule, + searchTerm: widget.searchTerm, + inputSelected: widget.inputSelectedVal, + outputSelected: widget.outputSelectedVal, + inoutSelected: widget.inoutSelectedVal) + ]); } - List generateSignalsRow( - TreeModel module, - String? searchTerm, - bool inputSelected, - bool outputSelected, - ) { - List rows = []; + /// Builds the rows for the signals that match the selected filters. + List generateSignalsRow(TreeModel module, + {required String? searchTerm, + required bool inputSelected, + required bool outputSelected, + required bool inoutSelected}) { + final rows = []; // Filter signals - List inputSignals = inputSelected + final inputSignals = inputSelected ? SignalService.filterSignals(module.inputs, searchTerm ?? '') - : []; - List outputSignals = outputSelected + : []; + final outputSignals = outputSelected ? SignalService.filterSignals(module.outputs, searchTerm ?? '') - : []; + : []; + final inoutSignals = inoutSelected + ? SignalService.filterSignals(module.inouts, searchTerm ?? '') + : []; // Add input from signal model list to row - for (var signal in inputSignals) { + for (final signal in inputSignals) { rows.add(_generateSignalRow(signal)); } // Add output from signal model list to row - for (var signal in outputSignals) { + for (final signal in outputSignals) { + rows.add(_generateSignalRow(signal)); + } + + for (final signal in inoutSignals) { rows.add(_generateSignalRow(signal)); } return rows; } - TableRow _generateSignalRow(SignalModel signal) { - return TableRow( - children: [ - SizedBox( - height: 32, - child: Center( - child: Text(signal.name), - ), - ), + TableRow _generateSignalRow(SignalModel signal) => + TableRow(children: [ + SizedBox(height: 32, child: Center(child: Text(signal.name))), + SizedBox(height: 32, child: Center(child: Text(signal.direction))), + SizedBox(height: 32, child: Center(child: Text(_lookupValue(signal)))), SizedBox( - height: 32, - child: Center( - child: Text(signal.direction), - ), - ), - SizedBox( - height: 32, - child: Center( - child: Text(signal.value), - ), - ), - SizedBox( - height: 32, - child: Center( - child: Text(signal.width.toString()), - ), - ), - ], - ); + height: 32, child: Center(child: Text(signal.width.toString()))) + ]); + + String _lookupValue(SignalModel signal) { + // Snapshot overlay is currently keyed by signal name because the upstream + // baseline does not yet thread a stable hierarchy-address identity through + // the details table path. This keeps live values working now and leaves a + // clear seam for the later hierarchy-address migration. + final snapshotData = widget.snapshot; + if (snapshotData != null) { + final ss = snapshotData.getSignalByName(signal.name); + if (ss != null) { + return ss.value; + } + } + return signal.value; } - Widget _buildTableHeader({required String text}) { - return SizedBox( + Widget _buildTableHeader({required String text}) => SizedBox( height: 32, child: Center( - child: Text( - text, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - ), - ); - } + child: Text(text, + style: + const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)))); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart index 4696ac39f..23a4e480b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // signal_table_text_field.dart @@ -7,25 +7,133 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class SignalTableTextField extends StatelessWidget { +/// A text field widget for filtering signals in the signal table. +/// +/// Supports regex patterns (indicated by hint text). Includes a prefix +/// filter icon and a clear button that appears when text is entered. +class SignalTableTextField extends StatefulWidget { + /// The label text for the text field. final String labelText; + + /// Callback when the text field value changes. final ValueChanged onChanged; + /// Creates a [SignalTableTextField] with the given label and change callback. const SignalTableTextField({ - super.key, required this.labelText, required this.onChanged, + super.key, }); + @override + State createState() => _SignalTableTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('labelText', labelText)) + ..add( + ObjectFlagProperty>.has('onChanged', onChanged), + ); + } +} + +class _SignalTableTextFieldState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _clear() { + _controller.clear(); + widget.onChanged(''); + } + @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Expanded( - child: TextField( - onChanged: onChanged, - decoration: InputDecoration( - labelText: labelText, + child: SizedBox( + height: 32, + child: TextField( + controller: _controller, + onChanged: widget.onChanged, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black, + ), + decoration: InputDecoration( + hintText: '${widget.labelText} (regex supported)', + hintStyle: TextStyle( + fontSize: 12, + color: isDark ? Colors.white38 : Colors.black38, + ), + prefixIcon: Icon( + Icons.filter_list, + size: 16, + color: isDark ? Colors.white38 : Colors.black38, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + suffixIcon: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) { + if (value.text.isEmpty) { + return const SizedBox.shrink(); + } + return IconButton( + icon: Icon( + Icons.clear, + size: 16, + color: isDark ? Colors.white38 : Colors.black38, + ), + onPressed: _clear, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ); + }, + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide( + color: isDark ? Colors.white24 : Colors.black12, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide( + color: isDark ? Colors.white24 : Colors.black12, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.03), + ), ), ), ); diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/simulation_time_display.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/simulation_time_display.dart new file mode 100644 index 000000000..757a833a5 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/simulation_time_display.dart @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// simulation_time_display.dart +// Display-level formatter for simulation time values. +// +// 2026 July +// Author: Desmond Kirkpatrick + +/// Display settings for simulation time values. +class SimulationTimeDisplay { + /// Default display settings when no unit is known. + static const none = SimulationTimeDisplay(); + + /// Optional unit suffix used when formatting simulation time. + final String? unit; + + /// Creates display settings for simulation time values. + const SimulationTimeDisplay({this.unit}); + + /// Formats [time] with the configured unit, if any. + String format(int time) { + final trimmedUnit = unit?.trim(); + if (trimmedUnit == null || trimmedUnit.isEmpty) { + return time.toString(); + } + + return '$time$trimmedUnit'; + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart new file mode 100644 index 000000000..62cbe2ed2 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart @@ -0,0 +1,380 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// standalone_app_shell.dart +// Minimal standalone shell for early startup/connection porting. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/connection_state_machine.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/view/tree_structure_page.dart'; + +/// Configuration for the standalone ROHD DevTools app shell. +class StandaloneAppConfig { + /// Title shown in AppBar. + final String title; + + /// Strategy for connecting to VM service. + final VmConnectionStrategy? connectionStrategy; + + /// Constructor for [StandaloneAppConfig]. + const StandaloneAppConfig({ + this.title = 'ROHD DevTools (Standalone)', + this.connectionStrategy, + }); +} + +/// Standalone app entry point that wires up theming and the app shell. +class StandaloneRohdDevToolsApp extends StatelessWidget { + /// Configuration used by the standalone app shell. + final StandaloneAppConfig config; + + /// Creates the standalone ROHD DevTools app. + const StandaloneRohdDevToolsApp({ + super.key, + this.config = const StandaloneAppConfig(), + }); + + @override + + /// Builds the top-level app and injects theme state. + Widget build(BuildContext context) => BlocProvider( + create: (context) => DevToolsThemeCubit(), + child: BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + + return MaterialApp( + title: config.title, + debugShowCheckedModeBanner: false, + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + darkTheme: buildDarkTheme(), + theme: buildLightTheme(), + home: StandaloneDevToolsPage(config: config), + ); + }, + ), + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('config', config)); + } +} + +/// The main standalone page that manages connections and content. +class StandaloneDevToolsPage extends StatefulWidget { + /// Configuration for the standalone page. + final StandaloneAppConfig config; + + /// Creates the standalone DevTools page. + const StandaloneDevToolsPage({required this.config, super.key}); + + @override + + /// Creates the mutable state for [StandaloneDevToolsPage]. + State createState() => _StandaloneDevToolsPageState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('config', config)); + } +} + +class _StandaloneDevToolsPageState + extends DevToolsConnectionHostState { + late final RohdServiceCubit _rohdServiceCubit = RohdServiceCubit( + manageServiceManager: false, + ); + late final SnapshotCubit _snapshotCubit = SnapshotCubit(); + late final TreeSearchTermCubit _treeSearchTermCubit = TreeSearchTermCubit(); + late final SelectedModuleCubit _selectedModuleCubit = SelectedModuleCubit(); + late final SignalSearchTermCubit _signalSearchTermCubit = + SignalSearchTermCubit(); + + @override + + /// Returns the connection strategy requested by the widget config. + VmConnectionStrategy? get connectionStrategy => + widget.config.connectionStrategy; + + @override + + /// Initializes the connection dialog and supporting listeners. + void initState() { + super.initState(); + // Auto-pop the connection dialog after the first frame, once + // fonts have settled on web (so glyphs render correctly). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !isConnected) { + unawaited(_showConnectionDialogWhenReady()); + } + }); + } + + /// Wait for icon fonts to load on web before showing the dialog so + /// the form's glyphs (e.g. dropdown chevrons) render on the first + /// frame instead of as boxes. No-op on native platforms. + Future _showConnectionDialogWhenReady() async { + if (kIsWeb) { + final completer = Completer(); + void onFontsChanged() { + if (!completer.isCompleted) { + completer.complete(); + } + } + + PaintingBinding.instance.systemFonts.addListener(onFontsChanged); + await completer.future.timeout( + const Duration(milliseconds: 1500), + onTimeout: () {}, + ); + PaintingBinding.instance.systemFonts.removeListener(onFontsChanged); + + // Give CanvasKit one extra frame to rasterise the glyphs. + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + await WidgetsBinding.instance.endOfFrame; + } + } + if (!mounted || isConnected) { + return; + } + await showConnectionDialog(); + } + + @override + + /// Handles a successful VM connection by configuring the ROHD service. + Future onVmConnected(VmConnectionResult result, String uri) async { + await _rohdServiceCubit.configureStandaloneVmService( + result.vmService, + result.isolateId, + ); + await _loadHierarchyFromVm(); + } + + @override + Future onCsmLoadHierarchy() => _loadHierarchyFromVm(); + + @override + + /// Clears the standalone tree service when the connection is torn down. + Future tearDownOldConnection({ + required VmConnectionTransition transition, + }) async { + _rohdServiceCubit.treeService = null; + } + + @override + + /// Reopens the connection dialog after the VM disconnects. + void onVmDisconnected() { + // Re-pop the connection dialog so the user can reconnect. + if (mounted) { + unawaited(showConnectionDialog()); + } + } + + @override + + /// Reconfigures the ROHD service after a lightweight reconnect. + Future onLightweightReconnectSuccess( + VmConnectionResult result, + String uri, + ) async { + await _rohdServiceCubit.configureStandaloneVmService( + result.vmService, + result.isolateId, + ); + await _loadHierarchyFromVm(); + } + + Future _loadHierarchyFromVm() async { + await _rohdServiceCubit.evalModuleTree(); + + final state = _rohdServiceCubit.state; + final success = switch (state) { + RohdServiceLoaded(treeModel: _) => true, + _ => false, + }; + + connectionStateMachine.handleEvent(HierarchyLoadResult(success: success)); + } + + @override + + /// Releases cubits used by the standalone shell. + void dispose() { + unawaited(_rohdServiceCubit.close()); + unawaited(_snapshotCubit.close()); + unawaited(_treeSearchTermCubit.close()); + unawaited(_selectedModuleCubit.close()); + unawaited(_signalSearchTermCubit.close()); + super.dispose(); + } + + void _openConnectionDialog() => unawaited(showConnectionDialog()); + + void _disconnect() => unawaited(disconnect()); + + /// Override the base dialog content to wire dismiss-on-success and + /// the standalone shell's discovered-services memory. + @override + + /// Builds the standalone connection dialog content. + Widget buildConnectionDialogContent(BuildContext dialogContext) => + VmConnectionForm( + vmServiceUriController: vmServiceUriController, + dtdUriController: dtdUriController, + connectionError: connectionError, + onConnect: () async { + try { + await attemptConnection(); + if (mounted && dialogContext.mounted && isConnected) { + Navigator.of(dialogContext).pop(); + } + } on Exception catch (e) { + setState(() { + connectionError = 'Connection failed: $e'; + }); + } + }, + cleanVmServiceUri: DevToolsConnectionHostState.cleanVmServiceUri, + cleanDtdUri: DevToolsConnectionHostState.cleanDtdUri, + discoverVmServices: discoverVmServices, + hasColorEmoji: true, + initialDiscoveredServices: rememberedServices + ?.map( + (s) => DiscoveredVmService( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(), + onServicesDiscovered: (services) { + rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(); + }, + ); + + /// Builds the empty state shown before any connection is established. + Widget _buildEmptyConnectionState() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final secondaryTextColor = isDark ? Colors.white70 : Colors.black54; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cable_outlined, + size: 72, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + const Text( + 'Not connected', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Text( + 'Connect to a running ROHD application to begin.', + style: TextStyle(color: secondaryTextColor), + ), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(Icons.link), + label: const Text('Connect…'), + onPressed: () => unawaited(showConnectionDialog()), + ), + ], + ), + ); + } + + @override + + /// Builds the standalone shell, switching between connected and empty UI. + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accentColor = Theme.of(context).colorScheme.primary; + return Scaffold( + appBar: AppBar( + title: Text(widget.config.title), + actions: [ + if (isConnected) ...[ + IconButton( + tooltip: 'Disconnect', + onPressed: _disconnect, + icon: const Icon(Icons.link_off), + ), + ] else + IconButton( + tooltip: 'Connect…', + onPressed: _openConnectionDialog, + icon: const Icon(Icons.link), + ), + BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + + return IconButton( + tooltip: + isDark ? 'Switch to light theme' : 'Switch to dark theme', + onPressed: () { + context.read().toggleTheme(); + }, + icon: platformIcon( + isDark ? Icons.light_mode : Icons.dark_mode, + isDark ? '☀️' : '🌙', + size: 24, + color: accentColor, + hasColorEmoji: kIsWeb, + ), + ); + }, + ), + DevToolsHelpButton(isDark: isDark), + ], + ), + body: !isConnected + ? _buildEmptyConnectionState() + : MultiBlocProvider( + providers: [ + BlocProvider.value(value: _rohdServiceCubit), + BlocProvider.value(value: _snapshotCubit), + BlocProvider.value(value: _treeSearchTermCubit), + BlocProvider.value(value: _selectedModuleCubit), + BlocProvider.value(value: _signalSearchTermCubit), + BlocProvider(create: (context) => DetailsTabCubit()), + ], + child: TreeStructurePage(screenSize: MediaQuery.of(context).size), + ), + ); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart new file mode 100644 index 000000000..7f5463121 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// ui.dart +// Barrel file for rohd_devtools UI widgets. +// +// NOTE: standalone_app_shell.dart is excluded because it imports this barrel. + +export 'details_help_button.dart'; +export 'devtool_appbar.dart'; +export 'devtools_connection_host.dart'; +export 'devtools_help_button.dart'; +export 'module_tree_card.dart'; +export 'module_tree_details_navbar.dart'; +export 'platform_icon.dart'; +export 'schematic_icon.dart'; +export 'signal_details_card.dart'; +export 'signal_table.dart'; +export 'signal_table_text_field.dart'; +export 'simulation_time_display.dart'; +export 'vm_connection_form.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart new file mode 100644 index 000000000..74848f14f --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart @@ -0,0 +1,729 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// vm_connection_form.dart +// Reusable VM connection form widget for both initial screen and dialog. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; + +/// Describes a single VM service discovered via DTD. +class DiscoveredVmService with Diagnosticable { + /// Human-readable name (may be null). + final String? name; + + /// Direct VM service URI. + final String uri; + + /// Exposed/forwarded URI (preferred over [uri] when available). + final String? exposedUri; + + /// Whether this VM service is currently reachable. + /// + /// Set to `false` by auto-rediscovery when the service is no longer + /// found via the DTD. Dead services are shown grayed-out in the list. + bool isAlive; + + /// Whether to automatically reconnect to this VM by name if it dies + /// and a new VM with the same name appears via DTD discovery. + bool autoReconnect; + + /// The URI to use for connection (prefers exposedUri). + String get connectionUri => exposedUri ?? uri; + + /// Construction for [DiscoveredVmService]. + DiscoveredVmService({ + required this.uri, + this.name, + this.exposedUri, + this.isAlive = true, + this.autoReconnect = false, + }); + + /// A compact display label. + String get displayLabel { + final label = name ?? 'VM Service'; + final preview = connectionUri.length > 50 + ? '${connectionUri.substring(0, 50)}…' + : connectionUri; + return '$label — ' + '$preview'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('name', name)) + ..add(StringProperty('uri', uri)) + ..add(StringProperty('exposedUri', exposedUri)) + ..add(FlagProperty('isAlive', value: isAlive)) + ..add(FlagProperty('autoReconnect', value: autoReconnect)) + ..add(StringProperty('connectionUri', connectionUri)) + ..add(StringProperty('displayLabel', displayLabel)); + } +} + +/// Callback that discovers VM services from a DTD URI. +/// +/// Returns the list of services found, or throws on error. +typedef DiscoverVmServicesCallback = Future> Function( + String dtdUri); + +/// Reusable VM connection form that can be embedded in different contexts. +/// +/// This widget encapsulates the DTD URI discovery and VM Service URI input, +/// and can be used both as the initial connection screen and in dialogs. +/// +/// Layout (top to bottom): +/// 1. DTD URI field + Discover button +/// 2. Discovered VM list (when available) +/// 3. VM Service URI field (manual override) +/// 4. Connect button +class VmConnectionForm extends StatefulWidget { + /// Controller for VM Service URI + final TextEditingController vmServiceUriController; + + /// Controller for DTD URI + final TextEditingController dtdUriController; + + /// Current connection error message (if any) + final String? connectionError; + + /// Callback when Connect button is pressed + final VoidCallback onConnect; + + /// Callback when Demo mode button is pressed (optional) + final VoidCallback? onDemoMode; + + /// Whether to show the demo mode button and help text + final bool showDemoButton; + + /// Whether emoji colors are available (for platform icons) + final bool hasColorEmoji; + + /// Callback to clean VM Service URIs + final String Function(String) cleanVmServiceUri; + + /// Callback to clean DTD URIs + final String Function(String) cleanDtdUri; + + /// Callback that discovers VM services from a DTD URI. + final DiscoverVmServicesCallback? discoverVmServices; + + /// Previously discovered services to pre-populate the list. + /// + /// When returning to the connection screen after a VM death, the parent + /// passes the remembered list (with [DiscoveredVmService.isAlive] set + /// appropriately) so the user can see which VMs are still available. + final List? initialDiscoveredServices; + + /// Called whenever the form discovers (or re-discovers) VM services. + /// + /// The parent should save this list so it can be passed back as + /// [initialDiscoveredServices] if the connection screen is shown again. + final ValueChanged>? onServicesDiscovered; + + /// Construction for [VmConnectionForm]. + const VmConnectionForm({ + required this.vmServiceUriController, + required this.dtdUriController, + required this.onConnect, + required this.cleanVmServiceUri, + required this.cleanDtdUri, + this.connectionError, + this.onDemoMode, + this.showDemoButton = false, + this.hasColorEmoji = false, + this.discoverVmServices, + this.initialDiscoveredServices, + this.onServicesDiscovered, + super.key, + }); + + @override + + /// Creates the state object for the VM connection form. + State createState() => _VmConnectionFormState(); + + @override + + /// Adds diagnostic properties for the connection form. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty( + 'vmServiceUriController', + vmServiceUriController, + ), + ) + ..add( + DiagnosticsProperty( + 'dtdUriController', + dtdUriController, + ), + ) + ..add(StringProperty('connectionError', connectionError)) + ..add( + ObjectFlagProperty( + 'onConnect', + onConnect, + ifNull: 'disabled', + ), + ) + ..add( + ObjectFlagProperty( + 'onDemoMode', + onDemoMode, + ifNull: 'disabled', + ), + ) + ..add(FlagProperty('showDemoButton', value: showDemoButton)) + ..add(FlagProperty('hasColorEmoji', value: hasColorEmoji)) + ..add( + DiagnosticsProperty( + 'cleanVmServiceUri', + cleanVmServiceUri, + ), + ) + ..add( + DiagnosticsProperty( + 'cleanDtdUri', + cleanDtdUri, + ), + ) + ..add( + ObjectFlagProperty( + 'discoverVmServices', + discoverVmServices, + ifNull: 'disabled', + ), + ) + ..add( + DiagnosticsProperty?>( + 'initialDiscoveredServices', + initialDiscoveredServices, + ), + ) + ..add( + ObjectFlagProperty>?>( + 'onServicesDiscovered', + onServicesDiscovered, + ifNull: 'disabled', + ), + ); + } +} + +class _VmConnectionFormState extends State { + List? _discoveredServices; + bool _isDiscovering = false; + bool _discoveryCancelled = false; + String? _discoveryError; + + static const _uriTextStyle = TextStyle( + fontFamily: 'monospace', + letterSpacing: 0, + ); + + TextStyle? get _nativeUriTextStyle => kIsWeb ? null : _uriTextStyle; + + void _connect() { + final raw = widget.vmServiceUriController.text; + final cleaned = widget.cleanVmServiceUri(raw); + if (cleaned.isNotEmpty && cleaned != raw) { + widget.vmServiceUriController.text = cleaned; + widget.vmServiceUriController.selection = + TextSelection.collapsed(offset: cleaned.length); + } + widget.onConnect(); + } + + @override + void initState() { + super.initState(); + // Pre-populate with remembered services (may include dead ones) + if (widget.initialDiscoveredServices != null) { + _discoveredServices = List.from( + widget.initialDiscoveredServices!, + ); + } else if (widget.dtdUriController.text.isNotEmpty && + widget.discoverVmServices != null) { + // DTD URI is already set (e.g. app reload) — auto-discover. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_discoverServices()); + } + }); + } + } + + Future _discoverServices() async { + final raw = widget.dtdUriController.text; + if (raw.isEmpty) { + return; + } + + final cleaned = widget.cleanDtdUri(raw); + if (!cleaned.startsWith('ws')) { + return; + } + + widget.dtdUriController.text = cleaned; + + if (widget.discoverVmServices == null) { + return; + } + + setState(() { + _isDiscovering = true; + _discoveryCancelled = false; + _discoveryError = null; + _discoveredServices = null; + }); + + try { + final services = await widget.discoverVmServices!(cleaned); + if (!mounted || _discoveryCancelled) { + return; + } + setState(() { + _isDiscovering = false; + _discoveredServices = services; + if (services.isEmpty) { + _discoveryError = 'No VM services found. Is your app running?'; + } else if (services.length == 1) { + // Auto-select the only service and enable auto-reconnect + widget.vmServiceUriController.text = services.first.connectionUri; + services.first.autoReconnect = true; + } + }); + // Notify parent so it can remember these across reconnects + widget.onServicesDiscovered?.call(services); + } on Exception catch (e) { + if (!mounted) { + return; + } + + // Determine the specific error to display better messages + final errorStr = e.toString().toLowerCase(); + final errorMessage = _getDiscoveryErrorMessage(errorStr, cleaned); + + setState(() { + _isDiscovering = false; + _discoveryError = errorMessage; + }); + } + } + + /// Generates a user-friendly error message based on the exception type. + /// + /// Distinguishes between DTD connection errors (invalid address) + /// and other errors, providing specific guidance for each case. + String _getDiscoveryErrorMessage(String errorStr, String dtdUri) { + // Check for WebSocket connection errors (invalid DTD address) + if (errorStr.contains('websocket') || + errorStr.contains('connection') || + errorStr.contains('failed to connect') || + errorStr.contains('refused')) { + return 'Failed to connect to DTD address: $dtdUri. ' + 'Please verify the URI is correct and the Dart Tooling Daemon ' + 'is running.'; + } + + // Check for socket timeouts or DNS resolution errors + if (errorStr.contains('timeout') || errorStr.contains('dns')) { + return 'Connection to DTD timed out or could not resolve ' + 'address: $dtdUri. ' + 'Please verify the DTD address is reachable.'; + } + + // Check for certificate/SSL errors + if (errorStr.contains('certificate') || errorStr.contains('ssl')) { + return 'SSL/certificate error connecting to DTD. ' + 'Make sure the DTD certificate is valid.'; + } + + // For other errors, provide a generic message + return 'Discovery failed. Please verify the DTD URI is correct ' + 'and check the console for more details.'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title (only on full-screen layout) + if (widget.showDemoButton) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + platformIcon( + Icons.developer_board, + '🔧', + size: 32, + hasColorEmoji: widget.hasColorEmoji, + ), + const SizedBox(width: 12), + Text( + 'Connect to Dart VM', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + const SizedBox(height: 24), + ], + + // ── 1. DTD URI field ── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: widget.dtdUriController, + style: _nativeUriTextStyle, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + decoration: InputDecoration( + labelText: 'DTD URI (auto-discover VMs)', + hintText: 'ws://127.0.0.1:xxxxx/xxxxx=', + hintStyle: _nativeUriTextStyle, + border: const OutlineInputBorder(), + prefixIcon: platformIcon( + Icons.cloud, + '☁️', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + ), + onSubmitted: (_) => _discoverServices(), + ), + ), + const SizedBox(width: 8), + if (_isDiscovering) ...[ + const SizedBox( + height: 56, + child: ElevatedButton( + onPressed: null, + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 56, + child: TextButton( + onPressed: () { + setState(() { + _discoveryCancelled = true; + _isDiscovering = false; + }); + }, + child: const Text('Cancel'), + ), + ), + ] else + SizedBox( + height: 56, // match TextField height + child: ElevatedButton( + onPressed: _discoverServices, + child: const Text('Discover'), + ), + ), + ], + ), + const SizedBox(height: 8), + + // ── 2. Discovered VM list ── + if (_discoveredServices != null && + _discoveredServices!.isNotEmpty) ...[ + Builder( + builder: (context) { + final aliveCount = + _discoveredServices!.where((s) => s.isAlive).length; + final deadCount = _discoveredServices!.length - aliveCount; + final label = deadCount > 0 + ? '$aliveCount VM service(s) available ($deadCount ended):' + : '${_discoveredServices!.length} VM service(s) found:'; + return Text( + label, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white70 : Colors.black54, + ), + ); + }, + ), + const SizedBox(height: 4), + Container( + constraints: const BoxConstraints(maxHeight: 160), + decoration: BoxDecoration( + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + ), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: _discoveredServices!.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final svc = _discoveredServices![index]; + final isDead = !svc.isAlive; + final isSelected = !isDead && + widget.vmServiceUriController.text == svc.connectionUri; + return ListTile( + dense: true, + selected: isSelected, + selectedTileColor: isDark + ? Colors.blue.shade900.withValues(alpha: 0.4) + : Colors.blue.shade50, + leading: platformIcon( + isDead ? Icons.cloud_off : Icons.memory, + isDead ? '🔌' : '🔌', + size: 18, + hasColorEmoji: widget.hasColorEmoji, + color: isDead ? Colors.grey : null, + ), + title: Text( + svc.name ?? 'VM Service ${index + 1}', + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isDead ? Colors.grey : null, + ), + ), + subtitle: Text( + isDead + ? '${svc.connectionUri} (ended)' + : svc.connectionUri, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: isDead ? Colors.grey : null, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: isDead + ? null + : Tooltip( + message: 'Automatic Reconnect', + child: SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: svc.autoReconnect, + onChanged: (value) { + setState(() { + svc.autoReconnect = value ?? false; + }); + widget.onServicesDiscovered?.call( + _discoveredServices!, + ); + }, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + ), + onTap: () { + setState(() { + widget.vmServiceUriController.text = svc.connectionUri; + }); + // Alive VMs connect immediately; dead VMs just fill + // the URI field so the user can edit before connecting. + if (!isDead) { + widget.onConnect(); + } + }, + ); + }, + ), + ), + const SizedBox(height: 12), + ], + + // Discovery error + if (_discoveryError != null) ...[ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _discoveryError!, + style: const TextStyle(color: Colors.orange, fontSize: 12), + ), + ), + const SizedBox(height: 12), + ], + + // ── 3. VM Service URI field (manual / override) ── + TextField( + controller: widget.vmServiceUriController, + style: _nativeUriTextStyle, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + decoration: InputDecoration( + labelText: 'VM Service URI', + hintText: 'ws://127.0.0.1:8181/xxxx=/ws', + hintStyle: _nativeUriTextStyle, + border: const OutlineInputBorder(), + prefixIcon: platformIcon( + Icons.link, + '🔗', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + ), + onSubmitted: (_) => _connect(), + ), + const SizedBox(height: 16), + + // Connection error + if (widget.connectionError != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + platformIcon( + Icons.error, + '❌', + color: Colors.red, + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.connectionError!, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ), + if (widget.connectionError != null) const SizedBox(height: 16), + + // ── 4. Connect button ── + ElevatedButton.icon( + onPressed: _connect, + icon: platformIcon( + Icons.power, + '⚡', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + label: const Text('Connect'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + + // Full-screen layout: demo mode button and help text + if (widget.showDemoButton && widget.onDemoMode != null) ...[ + const SizedBox(height: 16), + + // Divider + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR', + style: TextStyle( + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 16), + + // Demo mode button + OutlinedButton.icon( + onPressed: widget.onDemoMode, + icon: platformIcon( + Icons.play_arrow, + '▶️', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + label: const Text('Continue without Connection (Demo examples)'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + const SizedBox(height: 24), + + // Help text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To connect to a running ROHD app:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + const SizedBox(height: 8), + Text( + '1. Run your app with: dart run -- ' + 'observe your_app.dart\n' + '2. Copy the VM service URI from the console\n' + '3. Paste it above and click Connect', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart index fd880f56b..e6c352864 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_devtools_page.dart @@ -7,60 +7,60 @@ // 2025 January 28 // Author: Roberto Torres -import 'package:devtools_app_shared/service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; import 'package:rohd_devtools_extension/rohd_devtools/rohd_devtools.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/devtool_appbar.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +/// Main page for the embedded ROHD DevTools experience. class RohdDevToolsPage extends StatelessWidget { + /// Creates the DevTools page. const RohdDevToolsPage({super.key}); + @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => RohdServiceCubit(), - ), - BlocProvider( - create: (context) => TreeSearchTermCubit(), - ), - BlocProvider( - create: (context) => SelectedModuleCubit(), - ), - BlocProvider( - create: (context) => SignalSearchTermCubit(), - ), - ], - child: const RohdExtensionModule(), - ); - } + + /// Builds the themed DevTools page and its bloc providers. + Widget build(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => DevToolsThemeCubit()), + BlocProvider(create: (context) => RohdServiceCubit()), + BlocProvider(create: (context) => TreeSearchTermCubit()), + BlocProvider(create: (context) => SelectedModuleCubit()), + BlocProvider(create: (context) => SignalSearchTermCubit()), + BlocProvider(create: (context) => DetailsTabCubit()), + BlocProvider(create: (context) => SnapshotCubit()) + ], + child: BlocBuilder( + builder: (context, themeMode) { + final theme = themeMode == DevToolsThemeMode.dark + ? buildDarkTheme() + : buildLightTheme(); + + return Theme(data: theme, child: const RohdExtensionModule()); + })); } +/// Extension module wrapper used by the DevTools host. class RohdExtensionModule extends StatefulWidget { + /// Creates the extension module. const RohdExtensionModule({super.key}); @override + + /// Creates the module state. State createState() => _RohdExtensionModuleState(); } class _RohdExtensionModuleState extends State { - late final EvalOnDartLibrary rohdControllerEval; - @override - void initState() { - super.initState(); - } - @override + /// Builds the module scaffold and tree view. Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; return Scaffold( - appBar: const DevtoolAppBar(), - body: TreeStructurePage( - screenSize: screenSize, - ), - ); + appBar: const DevtoolAppBar(), + body: TreeStructurePage(screenSize: screenSize)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart index 91c57b1b7..83dca6df7 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree_structure_page.dart @@ -7,195 +7,232 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/rohd_service_cubit.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/tree_search_term_cubit.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_details_card.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/module_tree_details_navbar.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/module_tree_card.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/selected_module_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; +/// Split-pane page showing the module tree and selected module details. class TreeStructurePage extends StatelessWidget { - TreeStructurePage({ - super.key, - required this.screenSize, - }); + /// Creates the tree structure page. + TreeStructurePage({required this.screenSize, super.key}); + /// Available size used to split the page into two panes. final Size screenSize; + /// Horizontal scroll controller for the tree pane. final ScrollController _horizontal = ScrollController(); + + /// Vertical scroll controller for the tree pane. final ScrollController _vertical = ScrollController(); + /// Boundary used when exporting the tree pane as PNG. + final GlobalKey _treeBoundaryKey = GlobalKey(); + @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - // Module Tree render here (Left Section) - SizedBox( - width: screenSize.width / 2, - height: screenSize.width / 2.6, - child: Card( - clipBehavior: Clip.antiAlias, + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('screenSize', screenSize)); + } + + @override + + /// Builds the split-pane tree structure page. + Widget build(BuildContext context) => MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + final snapshotCubit = context.read(); + + if (state is RohdServiceLoaded) { + final source = + context.read().signalValueSource; + if (source == null) { + return; + } + + if (snapshotCubit.mode != SignalTrackingMode.video) { + snapshotCubit.setMode(SignalTrackingMode.video); + } + + snapshotCubit.startVideoTracking(source); + } else if (state is RohdServiceInitial || + state is RohdServiceError) { + snapshotCubit.clear(); + } + }) + ], + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTreePane(context), + _buildDetailsPane(context) + ])))); + + Widget _buildTreePane(BuildContext context) => SizedBox( + width: screenSize.width / 2, + child: Card( + clipBehavior: Clip.antiAlias, + child: Stack(children: [ + RepaintBoundary( + key: _treeBoundaryKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(10), - // Module Tree Menu Bar - child: Row( - children: [ - const Icon(Icons.account_tree), - const SizedBox(width: 10), - const Text('Module Tree'), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox( - width: 200, - child: TextField( - onChanged: (value) { - context - .read() - .setTerm(value); - }, - decoration: const InputDecoration( - labelText: "Search Tree", - ), - ), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => context - .read() - .evalModuleTree(), - ), - ], - ), - ), - ], - ), - ), - // expand the available column - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _vertical, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - controller: _vertical, - child: Row( - children: [ - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _horizontal, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontal, - child: BlocBuilder( - builder: (context, state) { - if (state is RohdServiceLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } else if (state is RohdServiceLoaded) { - final futureModuleTree = - state.treeModel; - if (futureModuleTree == null) { - return Expanded( - child: Container( - padding: - const EdgeInsets.all(20), - child: const Text( - 'Friendly Notice: Please make ' - 'sure that you use build() method ' - 'to build your module and put ' - 'the breakpoint at the ' - 'simulation time.', - style: - TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - ), - ); - } else { - return ModuleTreeCard( - futureModuleTree: - futureModuleTree, - ); - } - } else if (state is RohdServiceError) { - return Center( - child: - Text('Error: ${state.error}'), - ); - } else { - return const Center( - child: Text('Unknown state'), - ); - } - }, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - - // Signal Table Right Section Module - SizedBox( - width: screenSize.width / 2, - height: screenSize.width / 2.6, - child: Card( - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ModuleTreeDetailsNavbar(), - Padding( - padding: const EdgeInsets.only(left: 20, right: 20), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: BlocBuilder( - builder: (context, state) { - if (state is SelectedModuleLoaded) { - final selectedModule = state.module; - return SignalDetailsCard( - module: selectedModule, - ); - } else { - return const Center( - child: Text('No module selected'), - ); - } - }, - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); + _buildTreeToolbar(context), + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _vertical, + child: SingleChildScrollView( + controller: _vertical, + child: Row(children: [ + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _horizontal, + child: SingleChildScrollView( + scrollDirection: + Axis.horizontal, + controller: _horizontal, + child: BlocBuilder< + RohdServiceCubit, + RohdServiceState>( + builder: (context, state) => + _buildTreeStateBody( + state))))) + ])))) + ])), + Positioned( + right: 8, + bottom: 8, + child: ExportPngButton( + onPressed: () => captureBoundaryToPng(context, + boundaryKey: _treeBoundaryKey, + filePrefix: 'module_tree'))) + ]))); + + Widget _buildTreeToolbar(BuildContext context) => Padding( + padding: const EdgeInsets.all(10), + child: Row(children: [ + const Icon(Icons.account_tree), + const SizedBox(width: 10), + const Text('Module Tree'), + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + SizedBox( + width: 200, + child: TextField( + onChanged: (value) { + context.read().setTerm(value); + }, + decoration: const InputDecoration(labelText: 'Search Tree'))), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => + context.read().evalModuleTree()) + ])) + ])); + + Widget _buildTreeStateBody(RohdServiceState state) { + if (state is RohdServiceLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is RohdServiceLoaded) { + final futureModuleTree = state.treeModel; + if (futureModuleTree == null) { + return Container( + padding: const EdgeInsets.all(20), + child: const Text( + 'Friendly Notice: Please make sure that you use build() ' + 'method to build your module and put the breakpoint at ' + 'the simulation time.', + style: TextStyle(fontSize: 20), + textAlign: TextAlign.center)); + } + + return ModuleTreeCard(futureModuleTree: futureModuleTree); + } + + if (state is RohdServiceError) { + return Center(child: Text('Error: ${state.error}')); + } + + return const Center(child: Text('Unknown state')); + } + + Widget _buildDetailsPane(BuildContext context) => SizedBox( + width: screenSize.width / 2, + child: Card( + clipBehavior: Clip.antiAlias, + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const ModuleTreeDetailsNavbar(), + Expanded( + child: BlocBuilder( + builder: (context, selectedTab) => + IndexedStack(index: selectedTab.index, children: [ + Padding( + padding: + const EdgeInsets.only(left: 20, right: 20), + child: BlocBuilder( + builder: (context, state) => + BlocBuilder( + builder: (context, snapshotState) { + if (state is SelectedModuleLoaded) { + return SignalDetailsCard( + module: state.module, + snapshot: snapshotState + is SnapshotLoaded + ? snapshotState + : null); + } + + return const Center( + child: Text('No module selected')); + }))), + _buildFeaturePlaceholderPane(context, + icon: platformIcon(Icons.waves, '🌊', + size: 36, + color: Theme.of(context).colorScheme.primary, + hasColorEmoji: kIsWeb), + title: 'Waveform', + message: 'Waveform content will be available ' + 'in a future release.'), + _buildFeaturePlaceholderPane(context, + icon: const SchematicIcon(size: 36), + title: 'Schematic', + message: 'Schematic content will be available ' + 'in a future release.') + ]))) + ]))); + + Widget _buildFeaturePlaceholderPane(BuildContext context, + {required Widget icon, required String title, required String message}) { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + icon, + const SizedBox(height: 12), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text(message, + textAlign: TextAlign.center, + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.72))) + ])))); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools_observer.dart b/rohd_devtools_extension/lib/rohd_devtools_observer.dart index 42b14b167..780d97a4f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools_observer.dart +++ b/rohd_devtools_extension/lib/rohd_devtools_observer.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_devtools_observer.dart @@ -11,9 +11,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// [BlocObserver] observe all state changes in the application. class RohdDevToolsObserver extends BlocObserver { + /// Creates the observer used by the app. const RohdDevToolsObserver(); @override + + /// Forwards bloc state changes to the default observer behavior. void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); } diff --git a/rohd_devtools_extension/linux/.gitignore b/rohd_devtools_extension/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/rohd_devtools_extension/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/rohd_devtools_extension/linux/CMakeLists.txt b/rohd_devtools_extension/linux/CMakeLists.txt new file mode 100644 index 000000000..5b5c5f30f --- /dev/null +++ b/rohd_devtools_extension/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "rohd_devtools_extension") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.rohd_devtools_extension") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the QuickJS bridge library from flutter_js plugin (if it exists). +# This is needed because the flutter_js plugin's CMakeLists.txt has an issue +# where it doesn't properly export the bundled libraries variable. +set(QUICKJS_BRIDGE_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/flutter/ephemeral/.plugin_symlinks/flutter_js/linux/shared/libquickjs_c_bridge_plugin.so") +if(EXISTS "${QUICKJS_BRIDGE_SOURCE}") + install(FILES "${QUICKJS_BRIDGE_SOURCE}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/rohd_devtools_extension/linux/flutter/CMakeLists.txt b/rohd_devtools_extension/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..f6f23bfe9 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/rohd_devtools_extension/linux/flutter/generated_plugins.cmake b/rohd_devtools_extension/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..f16b4c342 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/rohd_devtools_extension/linux/runner/CMakeLists.txt b/rohd_devtools_extension/linux/runner/CMakeLists.txt new file mode 100644 index 000000000..e97dabc70 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/rohd_devtools_extension/linux/runner/main.cc b/rohd_devtools_extension/linux/runner/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/rohd_devtools_extension/linux/runner/my_application.cc b/rohd_devtools_extension/linux/runner/my_application.cc new file mode 100644 index 000000000..307532496 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "rohd_devtools_extension"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "rohd_devtools_extension"); + } + + gtk_window_set_default_size(window, 2100, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/rohd_devtools_extension/linux/runner/my_application.h b/rohd_devtools_extension/linux/runner/my_application.h new file mode 100644 index 000000000..72271d5e4 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/LICENSE b/rohd_devtools_extension/packages/rohd_devtools_widgets/LICENSE new file mode 100644 index 000000000..cfbbee995 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (C) 2021-2023 Intel Corporation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md b/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md new file mode 100644 index 000000000..90d3e3681 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md @@ -0,0 +1,13 @@ +# ROHD DevTools Widgets + +Shared Flutter widgets and utilities for ROHD DevTools debugger views. + +This package contains reusable UI pieces used across ROHD debugger tools such as schematic and waveform viewers. It is intended for common controls and presentation helpers that should stay consistent across DevTools packages, including shared menus, help system components, export helpers, and other supporting widgets. + +## Usage + +Add this package as a path dependency from a ROHD DevTools package and import the shared widgets you need: + +```dart +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; +``` diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml new file mode 100644 index 000000000..572dd239d --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart new file mode 100644 index 000000000..452567fae --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_devtools_widgets.dart +// Barrel file for the rohd_devtools_widgets package. +// Combines help_api, export_png, and overlay_api into one package. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// Help +export 'src/markdown_help_button.dart'; + +// Overlay +export 'src/app_bar_overlay.dart'; + +// PNG export +export 'src/capture_boundary.dart'; +export 'src/export_button.dart'; +export 'src/export_toast.dart'; +export 'src/save_png_stub.dart' + if (dart.library.io) 'src/save_png_native.dart' + if (dart.library.js_interop) 'src/save_png_web.dart'; + +// Cross-probing +export 'src/cross_probe_service.dart'; +export 'src/cross_probe_button.dart'; +export 'src/cross_probe_menu.dart'; + +// Logic type utilities +export 'src/logic_type_utils.dart'; + +// Bit-field parsing, formatting, and dialog utilities +export 'src/bit_field_utils.dart'; + +// Shared "Expand Bits" / "Define Bit Fields" popup-menu helpers +export 'src/bit_expansion_menu.dart'; + +// ROHD extension client +export 'src/rohd_extension_status.dart'; +export 'src/rohd_extension_client.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart new file mode 100644 index 000000000..ba210d5b2 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart @@ -0,0 +1,168 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_bar_overlay.dart +// Auto-hiding overlay AppBar that slides in from the top edge. +// +// When [autoHide] is true, the bar slides out of view and reappears when +// the mouse enters a thin trigger zone along the top edge. When [autoHide] +// is false the bar behaves like a normal AppBar (always visible, pushes +// content down). +// +// Designed to be reusable across ROHD Wave Viewer, Schematic Viewer, etc. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// Wraps a [body] widget and an [appBar] widget, where the AppBar +/// auto-hides by sliding up when [autoHide] is true. +/// +/// When [autoHide] is false the layout is a simple Column (AppBar + body), +/// matching normal Scaffold behaviour. +class AppBarOverlay extends StatefulWidget { + /// The AppBar-like widget to show/hide. + final PreferredSizeWidget appBar; + + /// The main content below the AppBar. + final Widget body; + + /// When true, the AppBar auto-hides and slides in on mouse hover. + /// When false, the AppBar is always visible. + final bool autoHide; + + /// Height of the invisible trigger zone along the top edge (pixels). + final double triggerHeight; + + /// Opacity of the overlay AppBar when shown (0.0–1.0). + final double panelOpacity; + + /// Duration of the slide animation. + final Duration animationDuration; + + const AppBarOverlay({ + super.key, + required this.appBar, + required this.body, + this.autoHide = false, + this.triggerHeight = 12, + this.panelOpacity = 0.92, + this.animationDuration = const Duration(milliseconds: 200), + }); + + @override + State createState() => _AppBarOverlayState(); +} + +class _AppBarOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + _slideAnimation = Tween( + begin: const Offset(0, -1), // fully off-screen above + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + // If not auto-hiding, snap open. + if (!widget.autoHide) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant AppBarOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.autoHide && oldWidget.autoHide) { + // Switched from auto-hide → always visible: snap open. + _controller.forward(); + } else if (widget.autoHide && !oldWidget.autoHide) { + // Switched from always visible → auto-hide: hide immediately. + _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _show() { + _controller.forward(); + } + + void _hide() { + if (!widget.autoHide) return; + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + // ── When not auto-hiding, simple column layout ── + if (!widget.autoHide) { + return Column( + children: [ + widget.appBar, + Expanded(child: widget.body), + ], + ); + } + + // ── Auto-hide mode: overlay with trigger zone ── + final appBarHeight = + widget.appBar.preferredSize.height + MediaQuery.of(context).padding.top; + + return Stack( + fit: StackFit.expand, + children: [ + // Body fills the entire area (no top inset — content goes edge-to-edge) + Positioned.fill(child: widget.body), + + // Trigger zone: thin invisible strip along the top edge + Positioned( + left: 0, + right: 0, + top: 0, + height: widget.triggerHeight, + child: MouseRegion( + onEnter: (_) => _show(), + opaque: false, // let clicks through when AppBar is hidden + child: const SizedBox.expand(), + ), + ), + + // Sliding overlay AppBar + Positioned( + left: 0, + right: 0, + top: 0, + height: appBarHeight, + child: SlideTransition( + position: _slideAnimation, + child: MouseRegion( + onEnter: (_) => _show(), + onExit: (_) => _hide(), + child: Opacity( + opacity: widget.panelOpacity, + child: widget.appBar, + ), + ), + ), + ), + ], + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart new file mode 100644 index 000000000..c01c55e31 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart @@ -0,0 +1,139 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_expansion_menu.dart +// Shared popup-menu items and dispatcher for the "Expand Bits" and +// "Define Bit Fields" actions. Used by all three surfaces that offer +// per-signal right-click menus: the waveform Signal-Selection overlay, +// the Selected-Signals panel, and the embedded Signal Details pane. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'bit_field_utils.dart'; + +/// Popup-menu values used by the bit-expansion items. +/// +/// Callers may match against these string constants when handling a +/// `showMenu` result that includes bit-expansion items. +abstract final class BitExpansionMenuValues { + /// Menu item: "Expand Bits [N]" — expand each bit (or a chosen range) + /// as a synthesized 1-bit waveform. + static const String expandBits = 'expand_bits'; + + /// Menu item: "Define Bit Fields [N]..." — open a dialog that lets the + /// user name arbitrary bit ranges. + static const String defineFields = 'define_fields'; +} + +/// Result of a bit-expansion menu interaction after any follow-up dialog +/// has resolved. Returned by [resolveBitExpansionMenuValue]. +sealed class BitExpansionAction { + const BitExpansionAction(); +} + +/// User picked "Expand Bits" and (implicitly or via dialog) chose the bit +/// range `[bitEnd:bitStart]` to expand into single-bit synthesized +/// waveforms. +class BitExpandRangeAction extends BitExpansionAction { + /// Low bit (inclusive) of the range to expand. + final int bitStart; + + /// High bit (inclusive) of the range to expand. + final int bitEnd; + + const BitExpandRangeAction(this.bitStart, this.bitEnd); +} + +/// User picked "Define Bit Fields..." and entered a non-empty list of +/// named [BitFieldDef]s. +class BitDefineFieldsAction extends BitExpansionAction { + /// The user-defined bit fields. + final List fields; + + const BitDefineFieldsAction(this.fields); +} + +/// Build the standard pair of popup-menu items shown when right-clicking +/// a single multi-bit signal: +/// +/// - **Expand Bits [width]** +/// - **Define Bit Fields [width]...** +/// +/// Callers should typically append these items to their existing +/// `PopupMenuEntry` list only when the signal selection contains +/// exactly one signal whose width is > 1. +/// +/// The optional [includeDivider] inserts a [PopupMenuDivider] before the +/// items so they visually separate from preceding items. +List> buildBitExpansionMenuItems({ + required int width, + double fontSize = 13, + double itemHeight = 32, + bool includeDivider = false, +}) { + return >[ + if (includeDivider) const PopupMenuDivider(height: 8), + PopupMenuItem( + height: itemHeight, + value: BitExpansionMenuValues.expandBits, + child: Text('Expand Bits [$width]', style: TextStyle(fontSize: fontSize)), + ), + PopupMenuItem( + height: itemHeight, + value: BitExpansionMenuValues.defineFields, + child: Text( + 'Define Bit Fields [$width]...', + style: TextStyle(fontSize: fontSize), + ), + ), + ]; +} + +/// Translate a popup-menu [value] returned by `showMenu` into a +/// [BitExpansionAction], showing any follow-up dialog as needed. +/// +/// Returns: +/// * [BitExpandRangeAction] when [value] is +/// [BitExpansionMenuValues.expandBits]. If [width] is at or below +/// [BitFieldUtils.expandThreshold] the full range `(0, width-1)` is +/// returned immediately. Otherwise [showBitRangeDialog] is invoked +/// and `null` is returned if the user cancels. +/// * [BitDefineFieldsAction] when [value] is +/// [BitExpansionMenuValues.defineFields]. [showDefineBitFieldsDialog] +/// is invoked; `null` is returned if the user cancels or enters no +/// fields. +/// * `null` for any other value (callers should handle their own +/// non-bit-expansion menu items first). +Future resolveBitExpansionMenuValue( + BuildContext context, { + required String? value, + required String signalName, + required int width, +}) async { + if (value == BitExpansionMenuValues.expandBits) { + if (width <= BitFieldUtils.expandThreshold) { + return BitExpandRangeAction(0, width - 1); + } + final parsed = await showBitRangeDialog( + context, + signalName: signalName, + width: width, + ); + if (parsed == null) return null; + final (high, low) = parsed; + return BitExpandRangeAction(low, high); + } + if (value == BitExpansionMenuValues.defineFields) { + final fields = await showDefineBitFieldsDialog( + context, + signalName: signalName, + width: width, + ); + if (fields == null || fields.isEmpty) return null; + return BitDefineFieldsAction(fields); + } + return null; +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart new file mode 100644 index 000000000..339057750 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart @@ -0,0 +1,257 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_field_utils.dart +// Shared bit-field parsing, formatting, and dialog utilities. +// +// 2025 May +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// A named bit-field definition within a bitvector signal. +class BitFieldDef { + /// Display name for this field (e.g. "exponent", "mantissa"). + final String name; + + /// High bit (inclusive, MSB of the field). + final int high; + + /// Low bit (inclusive, LSB of the field). + final int low; + + const BitFieldDef({ + required this.name, + required this.high, + required this.low, + }); + + /// Width of this field in bits. + int get width => high - low + 1; +} + +/// Shared utilities for parsing and formatting bit-field definitions. +abstract final class BitFieldUtils { + /// Number of elements/bits above which a confirmation pop-up is shown + /// before expanding an array, struct, or bitvector. + static const int expandThreshold = 8; + + /// Format a bit-range label from [startBit] and [width]. + /// + /// Returns e.g. `[7:4]` for startBit=4, width=4 or `[0]` for width=1. + static String formatBitRange(int startBit, int width) { + final highBit = startBit + width - 1; + return highBit == startBit ? '[$startBit]' : '[$highBit:$startBit]'; + } + + /// Parse a bit range string (`high:low`) or single bit index. + /// + /// Returns `(high, low)` clamped to `[0, maxBit]`, or `null` if invalid. + static (int, int)? parseBitRange(String input, int maxBit) { + if (input.contains(':')) { + final parts = input.split(':'); + if (parts.length != 2) return null; + final high = int.tryParse(parts[0].trim()); + final low = int.tryParse(parts[1].trim()); + if (high == null || low == null) return null; + final h = high.clamp(0, maxBit); + final l = low.clamp(0, maxBit); + return h >= l ? (h, l) : (l, h); + } + final bit = int.tryParse(input); + if (bit == null) return null; + final clamped = bit.clamp(0, maxBit); + return (clamped, clamped); + } + + /// Parse multi-line field definitions into [BitFieldDef] objects. + /// + /// Accepted formats per line: + /// - `name high:low` (e.g. `exponent 31:21`) + /// - `name high` (single bit, e.g. `sign 31`) + /// - `high:low` (unnamed, displayed as `[high:low]`) + /// - `bit` (unnamed single bit, displayed as `[bit]`) + static List parseBitFieldDefs(String input, int maxBit) { + final lines = input.split('\n'); + final fields = []; + for (final rawLine in lines) { + final line = rawLine.trim(); + if (line.isEmpty) continue; + + // Try: name high:low + final namedRange = RegExp(r'^(\w+)\s+(\d+):(\d+)$').firstMatch(line); + if (namedRange != null) { + final name = namedRange.group(1)!; + final a = int.parse(namedRange.group(2)!).clamp(0, maxBit); + final b = int.parse(namedRange.group(3)!).clamp(0, maxBit); + final high = a >= b ? a : b; + final low = a >= b ? b : a; + fields.add(BitFieldDef(name: name, high: high, low: low)); + continue; + } + + // Try: name bit (single bit) + final namedSingle = RegExp(r'^(\w+)\s+(\d+)$').firstMatch(line); + if (namedSingle != null) { + final name = namedSingle.group(1)!; + final bit = int.parse(namedSingle.group(2)!).clamp(0, maxBit); + fields.add(BitFieldDef(name: name, high: bit, low: bit)); + continue; + } + + // Try: high:low (unnamed) + final anonRange = RegExp(r'^(\d+):(\d+)$').firstMatch(line); + if (anonRange != null) { + final a = int.parse(anonRange.group(1)!).clamp(0, maxBit); + final b = int.parse(anonRange.group(2)!).clamp(0, maxBit); + final high = a >= b ? a : b; + final low = a >= b ? b : a; + fields.add(BitFieldDef(name: '[$high:$low]', high: high, low: low)); + continue; + } + + // Try: single number (unnamed single bit) + final anonSingle = RegExp(r'^(\d+)$').firstMatch(line); + if (anonSingle != null) { + final bit = int.parse(anonSingle.group(1)!).clamp(0, maxBit); + fields.add(BitFieldDef(name: '[$bit]', high: bit, low: bit)); + continue; + } + } + return fields; + } +} + +/// Show a dialog to select a bit range for a signal. +/// +/// Returns `(high, low)` or `null` if cancelled. +Future<(int, int)?> showBitRangeDialog( + BuildContext context, { + required String signalName, + required int width, +}) async { + final maxBit = width - 1; + final controller = TextEditingController(text: '$maxBit:0'); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + + final result = await showDialog( + context: context, + barrierColor: Colors.black26, + builder: (ctx) { + return AlertDialog( + title: Text( + '$signalName [$width bits]', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Bit range (high:low) or single bit', + hintText: '$maxBit:0', + isDense: true, + ), + onSubmitted: (value) => Navigator.of(ctx).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('OK'), + ), + ], + ); + }, + ); + + if (result == null || result.trim().isEmpty) return null; + return BitFieldUtils.parseBitRange(result.trim(), maxBit); +} + +/// Show a dialog to define named bit-field slices on a signal. +/// +/// Returns the parsed [BitFieldDef] list, or `null` if cancelled/empty. +Future?> showDefineBitFieldsDialog( + BuildContext context, { + required String signalName, + required int width, + List? existingDefs, +}) async { + final maxBit = width - 1; + + // Pre-fill with existing definitions if re-editing; append a trailing + // newline and place the cursor at the end so the user can immediately + // type additional fields without accidentally replacing existing ones. + final hasExisting = existingDefs != null && existingDefs.isNotEmpty; + final initialText = hasExisting + ? '${existingDefs.map((f) { + return f.high == f.low + ? '${f.name} ${f.high}' + : '${f.name} ${f.high}:${f.low}'; + }).join('\n')}\n' + : 'field0 $maxBit:0'; + + final controller = TextEditingController(text: initialText); + if (hasExisting) { + // Cursor at the end (after trailing newline) — ready for a new field. + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + } else { + // First time: select all default text for easy replacement. + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + } + + final result = await showDialog( + context: context, + barrierColor: Colors.black26, + builder: (ctx) { + return AlertDialog( + title: Text( + '$signalName [$width bits] — Define Fields', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 320, + child: TextField( + controller: controller, + autofocus: true, + maxLines: 8, + minLines: 3, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: InputDecoration( + labelText: 'One field per line: name high:low', + hintText: 'exponent $maxBit:${maxBit - 10}\n' + 'mantissa ${maxBit - 11}:0', + isDense: true, + border: const OutlineInputBorder(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('OK'), + ), + ], + ); + }, + ); + + if (result == null || result.trim().isEmpty) return null; + final fields = BitFieldUtils.parseBitFieldDefs(result, maxBit); + return fields.isEmpty ? null : fields; +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart new file mode 100644 index 000000000..172f79d75 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// capture_boundary.dart +// One-call RepaintBoundary → PNG export with toast feedback. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:typed_data' show Uint8List; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart' as export_png; + +/// Capture a [RepaintBoundary] identified by [boundaryKey], encode to PNG, +/// save/download, and show a toast. +/// +/// [filePrefix] is used as the first part of the file name +/// (e.g. `"schematic"` → `schematic_1713052800000.png`). +/// +/// When [saveFn] is provided it is used **instead** of the default platform +/// save/download. This allows callers (e.g. VS Code webview hosts) to route +/// the PNG bytes through a native Save dialog. [saveFn] receives the raw PNG +/// bytes and a suggested file name, and should return the saved path (or null +/// if no path feedback is available). +/// +/// [pixelRatio] controls the output resolution multiplier. Defaults to 2.0 +/// which works well in webview-constrained environments and keeps PNG sizes +/// manageable for postMessage serialisation. Callers that need print-quality +/// output (e.g. schematic exports) should pass a higher value explicitly. +/// +/// Returns `true` if the export succeeded. +Future captureBoundaryToPng( + BuildContext context, { + required GlobalKey boundaryKey, + String filePrefix = 'export', + double pixelRatio = 2.0, + Future Function(Uint8List pngBytes, String fileName)? saveFn, +}) async { + final boundary = + boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + debugPrint('[ExportPng] No RepaintBoundary found'); + return false; + } + + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + + if (byteData == null) { + debugPrint('[ExportPng] Failed to encode PNG'); + return false; + } + + final pngBytes = byteData.buffer.asUint8List(); + final fileName = '${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.png'; + + try { + final String? savedPath; + if (saveFn != null) { + savedPath = await saveFn(pngBytes, fileName); + } else { + savedPath = await export_png.savePngBytes(pngBytes, fileName); + } + final msg = + savedPath != null ? 'Saved: $savedPath' : 'Downloaded $fileName'; + debugPrint('[ExportPng] $msg'); + if (context.mounted) { + export_png.showExportToast(context, msg); + } + return true; + } on Object catch (e) { + debugPrint('[ExportPng] Export failed: $e'); + if (context.mounted) { + export_png.showExportToast(context, 'Export failed: $e'); + } + return false; + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart new file mode 100644 index 000000000..ea6148492 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_button.dart +// Toolbar button for toggling cross-probing between viewers. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'cross_probe_service.dart'; + +/// A toolbar icon button for cross-probing signal selections between viewers. +/// +/// Displays a bidirectional arrows icon ([Icons.compare_arrows]). Tap to +/// toggle cross-probing on or off via [CrossProbeService.isActive]. +/// +/// When active the icon is rendered in the theme's primary colour; when +/// inactive it uses the theme's disabled colour. +class CrossProbeButton extends StatelessWidget { + /// The cross-probe service whose [CrossProbeService.isActive] state is + /// reflected by this button. + final CrossProbeService service; + + /// Creates a [CrossProbeButton] for the given [service]. + const CrossProbeButton({required this.service, super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: service.isActive, + builder: (context, active, _) { + final color = active + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor; + return Tooltip( + message: active + ? 'Cross-probing active — tap to disable' + : 'Cross-probing disabled — tap to enable', + child: IconButton( + icon: Icon(Icons.compare_arrows, color: color), + onPressed: () => service.isActive.value = !service.isActive.value, + ), + ); + }, + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart new file mode 100644 index 000000000..b8134e4ad --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart @@ -0,0 +1,253 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_menu.dart +// Shared helpers for building generalized "Go to Source" cross-probe +// context-menu items across all viewers (wave, schematic, details). +// +// Viewers consult an [AvailableSourceFormats] query (backed by the cached +// ROHD extension module info) to discover which source languages are +// navigable, then build menu items via [buildGotoSourceMenuItems]. A single +// [GoToSourceCallback] handles the selection for every format, and the +// secondary frame picker (when a signal resolves to multiple frames) works +// uniformly for all formats. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'rohd_extension_status.dart'; + +/// Returns the source formats currently navigable for the active module. +/// +/// Must be synchronous (reads cached module info) so it can be consulted +/// while a popup menu is being built. +typedef AvailableSourceFormats = List Function(); + +/// Invoked when the user picks `Go to Source` for [signalPaths]. +typedef GoToSourceCallback = void Function( + RohdSourceFormat format, List signalPaths); + +/// Builds an icon for a source/output [format]. +typedef SourceFormatIconBuilder = Widget Function( + RohdSourceFormat format, { + double size, +}); + +/// Prefix used to encode source-navigation entries in a `String`-valued popup +/// menu (e.g. `'goto_source:rohd'`). Allows the shared items to coexist with +/// each viewer's other `String` menu values. +const String _gotoSourceValuePrefix = 'goto_source:'; + +/// Formats shown when source availability is unknown (module info not yet +/// loaded, the extension is unreachable, or the query errored). +const List kDefaultNavigableFormats = [ + RohdSourceFormat.rohd, + RohdSourceFormat.sv, +]; + +/// Encode a popup-menu value for navigating to [format]. +String gotoSourceMenuValue(RohdSourceFormat format) => + '$_gotoSourceValuePrefix${format.name}'; + +/// Decode a popup-menu value produced by [gotoSourceMenuValue]. +/// +/// Returns `null` when [value] is not a Go-to-Source entry, so callers can +/// fall through to handling their own menu values. +RohdSourceFormat? gotoSourceFormatFromValue(String? value) { + if (value == null || !value.startsWith(_gotoSourceValuePrefix)) { + return null; + } + final name = value.substring(_gotoSourceValuePrefix.length); + for (final f in RohdSourceFormat.values) { + if (f.name == name) { + return f; + } + } + return null; +} + +/// Short, menu-friendly name for [format] (e.g. `'ROHD'`, `'SV'`). +String gotoSourceShortName(RohdSourceFormat format) => switch (format) { + RohdSourceFormat.rohd => 'ROHD', + RohdSourceFormat.sv => 'SV', + RohdSourceFormat.sc => 'SystemC', + RohdSourceFormat.fst => 'Waveform', + }; + +/// Menu label for `Go to Source`, pluralized with [count]. +String gotoSourceMenuLabel(RohdSourceFormat format, {int count = 1}) { + final name = gotoSourceShortName(format); + return count <= 1 ? 'Go to $name Source' : 'Go to $name Source ($count)'; +} + +const _rohdIconAsset = 'assets/rohd_icon.png'; +const _systemVerilogIconAsset = 'assets/systemverilog_icon.png'; +const _systemCIconAsset = 'assets/systemc_icon.png'; + +/// App-bar-style icon for a source/output [format]. +Widget sourceFormatMenuIcon(RohdSourceFormat format, {double size = 18}) => + switch (format) { + RohdSourceFormat.rohd => _sourceFormatAssetIcon( + _rohdIconAsset, + semanticLabel: 'ROHD Source', + size: size, + ), + RohdSourceFormat.sv => _sourceFormatAssetIcon( + _systemVerilogIconAsset, + semanticLabel: 'SystemVerilog Source', + size: size, + ), + RohdSourceFormat.sc => _sourceFormatAssetIcon( + _systemCIconAsset, + semanticLabel: 'SystemC Source', + size: size, + ), + RohdSourceFormat.fst => Icon(Icons.timeline, size: size), + }; + +/// Backwards-compatible alias for [sourceFormatMenuIcon]. +Widget sourceFormatIcon(RohdSourceFormat format, {double size = 18}) => + sourceFormatMenuIcon(format, size: size); + +Widget _sourceFormatAssetIcon( + String asset, { + required String semanticLabel, + required double size, +}) => + Builder( + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final image = Image.asset( + asset, + width: size, + height: size, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + semanticLabel: semanticLabel, + errorBuilder: (context, error, stackTrace) => Image.asset( + asset, + package: 'rohd_devtools_widgets', + width: size, + height: size, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + semanticLabel: semanticLabel, + errorBuilder: (context, error, stackTrace) => + Icon(Icons.code, size: size), + ), + ); + + if (!isDark) return image; + + return Container( + width: size + 4, + height: size + 4, + decoration: const BoxDecoration( + color: Color(0xFFE0E0E0), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(2), + child: image, + ); + }, + ); + +/// Standard popup-menu row with a fixed-width prefix icon and ellipsized label. +Widget sourcePopupMenuRow({ + required Widget icon, + required String label, + TextStyle? textStyle, + double iconSlotWidth = 22, + double gap = 8, +}) => + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: iconSlotWidth, + child: Center(child: icon), + ), + SizedBox(width: gap), + Flexible( + child: Text(label, style: textStyle, overflow: TextOverflow.ellipsis), + ), + ], + ); + +/// Standard popup-menu item using the same fixed icon gutter as source rows. +PopupMenuItem buildRohdPopupMenuItem({ + required T value, + required Widget icon, + required String label, + double height = 32, + TextStyle? textStyle, + bool enabled = true, +}) => + PopupMenuItem( + value: value, + height: height, + enabled: enabled, + child: sourcePopupMenuRow( + icon: icon, + label: label, + textStyle: textStyle, + ), + ); + +/// Compact strip of source/output format icons for trace-picker menu rows. +Widget sourceFormatIconStrip({ + required Iterable formats, + SourceFormatIconBuilder iconBuilder = sourceFormatMenuIcon, + double size = 16, + double gap = 3, +}) { + final formatList = formats.toList(growable: false); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < formatList.length; i++) ...[ + if (i > 0) SizedBox(width: gap), + iconBuilder(formatList[i], size: size), + ], + ], + ); +} + +/// Resolve which navigable source formats to display for [info]. +/// +/// When [info] is `null`, the extension is unavailable, or the query errored, +/// availability is treated as *unknown* and [kDefaultNavigableFormats] is +/// returned so the actions stay available while metadata converges. +/// Otherwise the exact set of usable navigable formats is returned (which may +/// be empty when the module genuinely has no source). +List resolveNavigableFormats(RohdModuleInfo? info) { + if (info == null || !info.extensionAvailable || info.error != null) { + return kDefaultNavigableFormats; + } + return info.navigableSourceFormats; +} + +/// Build `Go to Source` popup-menu items for [formats]. +/// +/// Each item carries a value encoded by [gotoSourceMenuValue]; decode the +/// chosen value with [gotoSourceFormatFromValue] in the menu's result handler. +List> buildGotoSourceMenuItems({ + required List formats, + int count = 1, + double height = 32, + TextStyle? textStyle, + bool showIcons = true, + SourceFormatIconBuilder iconBuilder = sourceFormatMenuIcon, +}) => + [ + for (final format in formats) + buildRohdPopupMenuItem( + value: gotoSourceMenuValue(format), + height: height, + icon: showIcons ? iconBuilder(format) : const SizedBox.shrink(), + label: gotoSourceMenuLabel(format, count: count), + textStyle: textStyle, + ), + ]; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart new file mode 100644 index 000000000..694950740 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart @@ -0,0 +1,157 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_service.dart +// Interfaces and local implementations for cross-probing between viewers. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; + +/// Abstract interface for cross-probing signal selections between viewers. +/// +/// Cross-probing allows a user to select signals in one viewer (e.g. the +/// schematic) and have those signals automatically highlighted in all other +/// viewers (e.g. the waveform viewer). +abstract class CrossProbeService { + /// Whether cross-probing is currently active. + /// + /// When `false`, neither [send] broadcasts nor incoming messages from + /// the channel are delivered to [incomingSignals]. + ValueNotifier get isActive; + + /// The most recent incoming signal paths received from OTHER viewers. + /// + /// Updated whenever another viewer broadcasts a selection while + /// [isActive] is `true`. `null` until the first message arrives. + ValueNotifier?> get incomingSignals; + + /// Broadcast [signalPaths] from this viewer ([source]) to all others. + /// + /// [source] identifies the originating viewer (e.g. `'waveform'`, + /// `'schematic'`). [LocalCrossProbeService] uses this tag to filter + /// out its own broadcasts so it does not receive its own selections as + /// incoming signals. + /// + /// Does nothing when [isActive] is `false` or [signalPaths] is empty. + void send(List signalPaths, {required String source}); + + /// Release all resources held by this service. + void dispose(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Local (in-process) implementation +// ───────────────────────────────────────────────────────────────────────────── + +/// Shared in-process broadcast channel. +/// +/// Create a single [LocalCrossProbeChannel] and share it among all +/// [LocalCrossProbeService] instances that should cross-probe with each other. +/// This replaces the older `SignalSelectionBus` pattern. +class LocalCrossProbeChannel extends ChangeNotifier { + String? _lastSource; + List? _lastPaths; + + /// The source tag of the most recent broadcast. + String? get lastSource => _lastSource; + + /// The signal paths of the most recent broadcast. + List? get lastPaths => _lastPaths; + + /// Broadcast [signalPaths] from [source] to all registered listeners. + /// + /// Does nothing when [signalPaths] is empty. + void broadcast(List signalPaths, String source) { + if (signalPaths.isEmpty) return; + _lastSource = source; + _lastPaths = List.unmodifiable(signalPaths); + notifyListeners(); + } +} + +/// Per-viewer [CrossProbeService] backed by a shared [LocalCrossProbeChannel]. +/// +/// Create one [LocalCrossProbeService] per viewer, all sharing the same +/// [LocalCrossProbeChannel]. Each service filters out its own broadcasts +/// (matched by [source]) so viewers do not receive their own selections. +/// +/// ```dart +/// final channel = LocalCrossProbeChannel(); +/// final waveXp = LocalCrossProbeService(channel, source: 'waveform'); +/// final schemXp = LocalCrossProbeService(channel, source: 'schematic'); +/// +/// // Pass waveXp to the wave viewer and schemXp to the schematic viewer. +/// // dispose both services and the channel when done. +/// ``` +class LocalCrossProbeService implements CrossProbeService { + final LocalCrossProbeChannel _channel; + final String _source; + + @override + final ValueNotifier isActive = ValueNotifier(true); + + @override + final ValueNotifier?> incomingSignals = + ValueNotifier?>(null); + + /// Creates a [LocalCrossProbeService] backed by [channel]. + /// + /// [source] is the identifier used to filter self-broadcasts. Use a + /// stable, descriptive tag such as `'waveform'` or `'schematic'`. + LocalCrossProbeService( + LocalCrossProbeChannel channel, { + required String source, + }) : _channel = channel, + _source = source { + _channel.addListener(_onChannelMessage); + } + + void _onChannelMessage() { + if (!isActive.value) return; + final src = _channel.lastSource; + if (src == null || src == _source) return; // ignore own broadcasts + incomingSignals.value = _channel.lastPaths; + } + + @override + void send(List signalPaths, {required String source}) { + if (!isActive.value || signalPaths.isEmpty) return; + _channel.broadcast(signalPaths, source); + } + + @override + void dispose() { + _channel.removeListener(_onChannelMessage); + isActive.dispose(); + incomingSignals.dispose(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Null (no-op) implementation +// ───────────────────────────────────────────────────────────────────────────── + +/// A no-op [CrossProbeService] for standalone or offline contexts where +/// cross-probing between viewers is not available. +/// +/// [isActive] is always `false`; [send] is a no-op; [incomingSignals] +/// never changes. +class NullCrossProbeService implements CrossProbeService { + @override + final ValueNotifier isActive = ValueNotifier(false); + + @override + final ValueNotifier?> incomingSignals = + ValueNotifier?>(null); + + @override + void send(List signalPaths, {required String source}) {} + + @override + void dispose() { + isActive.dispose(); + incomingSignals.dispose(); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart new file mode 100644 index 000000000..4c0dd1327 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart @@ -0,0 +1,53 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_button.dart +// Reusable camera-icon button for PNG export. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// Small camera-icon button for triggering PNG export. +/// +/// Designed to be placed in a [Positioned] overlay. Calls [onPressed] +/// when tapped. +class ExportPngButton extends StatelessWidget { + /// Called when the export button is tapped. + final VoidCallback onPressed; + + /// Tooltip text shown on hover. + final String tooltip; + + const ExportPngButton({ + super.key, + required this.onPressed, + this.tooltip = 'Export as PNG', + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: Material( + color: cs.surface.withAlpha(200), + shape: const CircleBorder(), + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.camera_alt_outlined, + size: 20, + color: cs.onSurface, + ), + ), + ), + ), + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart new file mode 100644 index 000000000..e962a6dd0 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_toast.dart +// Overlay-based toast that works without a Scaffold ancestor. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Show a brief floating toast at the bottom of the screen. +/// +/// Works without a [Scaffold] ancestor by inserting directly into the +/// root [Overlay]. Auto-removes after [duration]. +void showExportToast( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text( + message, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + Timer(duration, entry.remove); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart new file mode 100644 index 000000000..adf89e928 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart @@ -0,0 +1,408 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// logic_type_utils.dart +// Utilities for expanding LogicStructure/LogicArray type metadata and +// extracting sub-field values via bit-slicing. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// A node in the expanded type tree, used for structured display. +class TypeFieldNode { + /// Field name (e.g. "mantissa", "[0]"). + final String name; + + /// Bit width of this field. + final int width; + + /// Extracted value string for this field, or null if unavailable. + final String? value; + + /// Child fields for nested structs/arrays. + final List children; + + /// The bit range within the parent: [startBit, endBit) (LSB-first). + final int startBit; + + /// Creates a type field node. + TypeFieldNode({ + required this.name, + required this.width, + this.value, + this.children = const [], + this.startBit = 0, + }); +} + +/// Expand a `logic_type` metadata map into a tree of [TypeFieldNode]s. +/// +/// If [parentBinaryValue] is provided (as a binary string, MSB-first), +/// sub-field values are extracted via bit-slicing. +/// +/// The `logic_type` format for structs: +/// ```json +/// {"typeName": "FloatingPoint", "fields": [ +/// {"name": "mantissa", "width": 4, "bits": [0,1,2,3]}, +/// {"name": "exponent", "width": 4, "bits": [4,5,6,7]}, +/// {"name": "sign", "width": 1, "bits": [8]} +/// ]} +/// ``` +/// +/// For arrays: +/// ```json +/// {"width": 80, "arrayDims": [10], "elementWidth": 8} +/// ``` +List expandLogicType( + Map? logicType, { + String? parentBinaryValue, +}) { + if (logicType == null) { + return const []; + } + + // Struct case + final fields = logicType['fields'] as List?; + if (fields != null) { + return _expandStructFields(fields, parentBinaryValue); + } + + // Array case + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null) { + final elementWidth = (logicType['elementWidth'] as int?) ?? 1; + final elementType = logicType['elementType'] as Map?; + return _expandArrayElements( + arrayDims.cast(), + elementWidth, + elementType, + parentBinaryValue, + ); + } + + return const []; +} + +/// Expand struct fields from the `fields` list. +List _expandStructFields( + List fields, + String? parentBinaryValue, +) { + // The `bits` arrays in struct metadata may use module-level absolute + // indices (e.g. [390..398] for a 9-bit signal). Normalize to signal- + // relative indices by subtracting the global minimum across all fields. + var baseOffset = 0; + if (parentBinaryValue != null) { + var minBit = 1 << 30; + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final bits = field['bits'] as List?; + if (bits != null && bits.isNotEmpty) { + for (final b in bits) { + final bInt = b as int; + if (bInt < minBit) { + minBit = bInt; + } + } + } + } + // Only apply offset if the bits exceed the binary value length, + // indicating module-level absolute indices. + if (minBit > 0 && minBit >= parentBinaryValue.length) { + baseOffset = minBit; + } + } + + final nodes = []; + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final name = field['name'] as String? ?? '?'; + final width = field['width'] as int? ?? 1; + final bits = field['bits'] as List?; + final nestedType = field['type'] as Map?; + + // Normalize bits to signal-relative indices. + final relativeBits = bits?.cast().map((b) => b - baseOffset).toList(); + + // Determine start bit from bits array (min value, relative). + final startBit = relativeBits != null && relativeBits.isNotEmpty + ? relativeBits.reduce((a, b) => a < b ? a : b) + : 0; + + // Extract value for this field. + String? fieldValue; + if (parentBinaryValue != null && + relativeBits != null && + relativeBits.isNotEmpty) { + fieldValue = _extractBitsFromBinary(parentBinaryValue, relativeBits); + } + + // Recursively expand nested types. + final children = nestedType != null + ? expandLogicType(nestedType, parentBinaryValue: fieldValue) + : const []; + + nodes.add( + TypeFieldNode( + name: name, + width: width, + value: fieldValue, + children: children, + startBit: startBit, + ), + ); + } + return nodes; +} + +/// Expand array elements. +List _expandArrayElements( + List dims, + int elementWidth, + Map? elementType, + String? parentBinaryValue, +) { + if (dims.isEmpty) { + return const []; + } + + final outerDim = dims.first; + final nodes = []; + + // The `elementWidth` from logicType is the LEAF element width. + // For multi-dimensional arrays the actual per-element width at this level + // is the product of remaining dimensions × leaf element width. + // Derive it from the parent binary length when available, or compute it. + final int stride; + if (parentBinaryValue != null && parentBinaryValue.isNotEmpty) { + stride = parentBinaryValue.length ~/ outerDim; + } else if (dims.length > 1) { + // Product of remaining dims × leaf element width. + var product = elementWidth; + for (var d = 1; d < dims.length; d++) { + product *= dims[d]; + } + stride = product; + } else { + stride = elementWidth; + } + + for (var i = 0; i < outerDim; i++) { + final startBit = i * stride; + + // Extract this element's value. + String? elementValue; + if (parentBinaryValue != null) { + elementValue = _extractContiguousBits( + parentBinaryValue, + startBit, + stride, + ); + } + + // For multi-dimensional arrays, recurse into inner dimensions. + List children; + if (dims.length > 1) { + // Only propagate elementType if it describes a leaf-level type (e.g. + // a struct). When elementType itself has 'arrayDims', it merely + // re-describes the intermediate structure already encoded in the + // parent's flat dims list — propagating it would incorrectly expand + // leaf elements with children that exceed their bit width. + final propagateElementType = + elementType != null && !elementType.containsKey('arrayDims'); + final innerType = { + 'arrayDims': dims.sublist(1), + 'elementWidth': elementWidth, + if (propagateElementType) 'elementType': elementType, + }; + children = expandLogicType(innerType, parentBinaryValue: elementValue); + } else if (elementType != null) { + children = expandLogicType(elementType, parentBinaryValue: elementValue); + } else { + children = const []; + } + + nodes.add( + TypeFieldNode( + name: '[$i]', + width: stride, + value: elementValue, + children: children, + startBit: startBit, + ), + ); + } + return nodes; +} + +/// Extract specified bit indices from a binary string (MSB-first format). +/// +/// The binary string is MSB-first: index 0 is the rightmost (LSB) bit. +/// The [bitIndices] are LSB-indexed (matching the netlist `bits` array). +String _extractBitsFromBinary(String binaryValue, List bitIndices) { + final totalWidth = binaryValue.length; + final result = StringBuffer(); + + // Sort indices descending to produce MSB-first output. + final sorted = List.from(bitIndices)..sort((a, b) => b.compareTo(a)); + + for (final idx in sorted) { + // Convert LSB index to MSB-first string position. + final pos = totalWidth - 1 - idx; + if (pos >= 0 && pos < totalWidth) { + result.write(binaryValue[pos]); + } else { + result.write('x'); + } + } + return result.toString(); +} + +/// Extract a contiguous bit range from a binary string (MSB-first format). +/// +/// [startBit] is the LSB index, [width] is the number of bits. +String _extractContiguousBits(String binaryValue, int startBit, int width) { + if (width <= 0) { + return ''; + } + + final totalWidth = binaryValue.length; + final endBit = startBit + width; // exclusive + if (startBit >= 0 && endBit <= totalWidth) { + return LogicValue.ofString(binaryValue) + .slice(endBit - 1, startBit) + .toString(includeWidth: false); + } + + final result = StringBuffer(); + + // Extract MSB-first. + for (var i = endBit - 1; i >= startBit; i--) { + final pos = totalWidth - 1 - i; + if (pos >= 0 && pos < totalWidth) { + result.write(binaryValue[pos]); + } else { + result.write('x'); + } + } + return result.toString(); +} + +/// Convert a hex value string (e.g. "0x1a3f" or "1a3f") to binary (MSB-first). +/// +/// Returns null if the input can't be parsed. +String? hexToBinary(String hexValue, int width) { + if (width <= 0) { + return ''; + } + + var cleaned = hexValue.trim().toLowerCase(); + if (cleaned.startsWith('0x')) { + cleaned = cleaned.substring(2); + } + if (cleaned.isEmpty) { + return null; + } + + final sourceWidth = cleaned.length * 4; + final parseWidth = sourceWidth > width ? sourceWidth : width; + try { + return LogicValue.ofRadixString("$parseWidth'h$cleaned") + .slice(width - 1, 0) + .toString(includeWidth: false); + } on Exception { + return null; + } +} + +/// Format a binary field value for display. +/// +/// Short values (<=4 bits) show as binary. Longer values show as hex. +/// Uses ROHD radixString style: width'hHEX. +String formatFieldValue(String? binaryValue, int width) { + if (binaryValue == null || binaryValue.isEmpty) { + return ''; + } + if (binaryValue.contains('x')) { + return "$width'hx"; + } + if (binaryValue.contains('z')) { + return "$width'hz"; + } + if (width <= 4) { + return "$width'b$binaryValue"; + } + // Convert to hex. + final bigInt = BigInt.tryParse(binaryValue, radix: 2); + if (bigInt == null) { + return binaryValue; + } + final hexDigits = (width + 3) ~/ 4; + final hex = bigInt.toRadixString(16).padLeft(hexDigits, '0'); + return "$width'h$hex"; +} + +/// Build a multi-line indented string showing struct/array fields with values. +/// +/// Used for schematic hover tooltips. +String formatTypeTooltip( + Map? logicType, { + String? parentBinaryValue, + String? signalName, + int maxDepth = 6, +}) { + if (logicType == null) { + return ''; + } + + final nodes = expandLogicType( + logicType, + parentBinaryValue: parentBinaryValue, + ); + if (nodes.isEmpty) { + return ''; + } + + final buf = StringBuffer(); + final typeName = logicType['typeName'] as String?; + if (signalName != null) { + buf.write(signalName); + if (typeName != null) { + buf.write(' ($typeName)'); + } + buf.writeln(); + } else if (typeName != null) { + buf.writeln(typeName); + } + + for (final node in nodes) { + _formatNode(buf, node, indent: 1, maxDepth: maxDepth); + } + return buf.toString().trimRight(); +} + +void _formatNode( + StringBuffer buf, + TypeFieldNode node, { + required int indent, + required int maxDepth, +}) { + final pad = ' ' * indent; + buf.write('$pad${node.name}'); + if (node.value != null) { + buf.write(': ${formatFieldValue(node.value, node.width)}'); + } else { + buf.write(' [${node.width}]'); + } + buf.writeln(); + + if (indent < maxDepth) { + for (final child in node.children) { + _formatNode(buf, child, indent: indent + 1, maxDepth: maxDepth); + } + } else if (node.children.isNotEmpty) { + buf.writeln('$pad ...'); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart new file mode 100644 index 000000000..713e6b0f1 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart @@ -0,0 +1,486 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// markdown_help_button.dart +// A generic help button driven by a markdown asset file. +// +// The markdown file contains two sections separated by : +// - Above the marker: plain-text tooltip shown on hover +// - Below the marker: markdown rendered in the click-open dialog +// +// The markdown file is also directly viewable in any markdown previewer +// (GitHub, VS Code, etc.) because both sections are valid markdown and +// the separator is an invisible HTML comment. +// +// Details section format: +// ## Heading → section heading +// | Key | Description | → key–description entry row (markdown table) +// Paragraphs → plain-text description +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +/// A help button that loads its content from a markdown asset file. +/// +/// The markdown file must contain a `` marker and a +/// `` marker. Text between those markers becomes the +/// hover tooltip; text after `` is rendered as the +/// click-open dialog body. +/// +/// The first line of the file (an `# H1` heading) is used as the dialog +/// title. Everything before `` is ignored at runtime +/// (it serves as the visible title when previewing the raw markdown). +/// +/// ### Markdown file layout +/// +/// ```markdown +/// # 🌳 My Tool — Help ← dialog title (H1) +/// +/// +/// +/// Short keybinding summary ← hover tooltip (plain text) +/// shown on mouse hover. +/// +/// +/// +/// ## Section ← dialog section heading +/// +/// | Key | Description | ← table header (required before rows) +/// |-----|-------------| +/// | F | Fit to canvas | ← key–description entry +/// +/// Any paragraph text. ← rendered as body text +/// ``` +class MarkdownHelpButton extends StatefulWidget { + /// Path to the markdown asset file (e.g. `assets/help/my_help.md`). + final String assetPath; + + /// Whether the current theme is dark mode. + final bool isDark; + + /// Optional override for the button label (defaults to `❓`). + final String label; + + /// Optional widget to use as the button icon instead of [label]. + /// + /// When non-null, this widget is displayed instead of `Text(label)`. + /// Use this on platforms where the emoji [label] would not render + /// (e.g. Linux without NotoColorEmoji), passing an `Icon(Icons.help_outline)` + /// or similar Material icon. + final Widget? labelIcon; + + /// Optional package name that owns the asset. + /// + /// When non-null the actual asset path becomes + /// `packages/$package/$assetPath`, which is how Flutter resolves assets + /// declared in dependency packages. + final String? package; + + /// Optional widget shown before the dialog title text. + /// + /// Use this to display a custom icon (e.g. a `CustomPaint` widget) + /// next to the dialog title instead of relying on emoji characters + /// that may not render on all platforms. + final Widget? titleIcon; + + /// Optional text substitutions applied to the markdown before parsing. + /// + /// Each key `K` replaces all occurrences of `{{K}}` in the raw markdown + /// with the corresponding value. For example: + /// ```dart + /// substitutions: {'VERSION': '1.2.3'} + /// ``` + /// will replace `{{VERSION}}` → `1.2.3` in the loaded asset. + final Map? substitutions; + + /// Create a [MarkdownHelpButton]. + const MarkdownHelpButton({ + required this.assetPath, + required this.isDark, + this.label = '❓', + this.labelIcon, + this.package, + this.titleIcon, + this.substitutions, + super.key, + }); + + @override + State createState() => _MarkdownHelpButtonState(); +} + +class _MarkdownHelpButtonState extends State { + /// Parsed help content, loaded once from the asset. + _HelpContent? _content; + + @override + void initState() { + super.initState(); + _loadContent(); + } + + @override + void didUpdateWidget(MarkdownHelpButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath || + oldWidget.package != widget.package) { + _loadContent(); + } + } + + Future _loadContent() async { + try { + String raw; + if (widget.package != null) { + // Try the package-qualified path first (works when embedded as a + // dependency in a host app), then fall back to the bare asset path + // (standalone mode). This order avoids a spurious 404 on the web + // when the bare path doesn't exist. + // Use catch-all because rootBundle.loadString throws FlutterError + // (an Error, not Exception) when the asset is missing. + try { + raw = await rootBundle + .loadString('packages/${widget.package}/${widget.assetPath}'); + // ignore: avoid_catches_without_on_clauses + } catch (_) { + raw = await rootBundle.loadString(widget.assetPath); + } + } else { + raw = await rootBundle.loadString(widget.assetPath); + } + // Apply substitutions before parsing. + final subs = widget.substitutions; + if (subs != null) { + for (final entry in subs.entries) { + raw = raw.replaceAll('{{${entry.key}}}', entry.value); + } + } + if (mounted) { + setState(() { + _content = _HelpContent.parse(raw); + }); + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + debugPrint('Failed to load help asset: $e'); + if (mounted) { + setState(() { + _content = _HelpContent.parse( + '# Help unavailable\n\n\n\n' + 'Help content could not be loaded.\n\n\n\n' + 'Error: $e', + ); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + final tooltip = _content?.tooltip ?? 'Loading help…'; + + return Tooltip( + message: tooltip, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + textStyle: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: isDark ? Colors.white : Colors.black87, + height: 1.4, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + if (_content != null) { + _showHelpDialog(context, _content!, + isDark: isDark, titleIcon: widget.titleIcon); + } + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: widget.labelIcon ?? + Text(widget.label, + style: const TextStyle(fontSize: 18, inherit: false)), + ), + ), + ), + ); + } + + /// Show the help dialog with parsed markdown content. + static void _showHelpDialog( + BuildContext context, + _HelpContent content, { + required bool isDark, + Widget? titleIcon, + }) { + final bgColor = isDark ? const Color(0xFF252526) : Colors.white; + final fgColor = isDark ? Colors.white : Colors.black87; + final headingColor = isDark ? Colors.blue[200]! : Colors.blue[800]!; + final keyColor = isDark ? Colors.amber[200]! : Colors.amber[900]!; + final dividerColor = isDark ? Colors.white24 : Colors.black12; + + final widgets = []; + for (final block in content.detailBlocks) { + if (block is _HeadingBlock) { + widgets.add(Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text(block.text, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: headingColor, + )), + )); + } else if (block is _EntryBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: Text(block.key, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: keyColor, + )), + ), + Expanded( + child: Text(block.description, + style: TextStyle(fontSize: 13, color: fgColor)), + ), + ], + ), + )); + } else if (block is _ParagraphBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: + Text(block.text, style: TextStyle(fontSize: 13, color: fgColor)), + )); + } + } + + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: bgColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row + Row( + children: [ + if (titleIcon != null) ...[ + titleIcon, + const SizedBox(width: 10), + ], + Expanded( + child: Text(content.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: fgColor, + )), + ), + IconButton( + icon: Icon(Icons.close, color: fgColor, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + Divider(color: dividerColor), + // Scrollable content + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Parsed help content model +// --------------------------------------------------------------------------- + +/// Parsed representation of a help markdown file. +class _HelpContent { + /// Dialog title (from the `# H1` heading). + final String title; + + /// Plain-text tooltip (between `` and ``). + final String tooltip; + + /// Parsed detail blocks (headings, entries, paragraphs). + final List<_DetailBlock> detailBlocks; + + _HelpContent({ + required this.title, + required this.tooltip, + required this.detailBlocks, + }); + + /// Parse a raw markdown string into [_HelpContent]. + factory _HelpContent.parse(String raw) { + const tooltipMarker = ''; + const detailsMarker = ''; + + final tooltipIdx = raw.indexOf(tooltipMarker); + final detailsIdx = raw.indexOf(detailsMarker); + + // Extract title from the first # heading. + String title = 'Help'; + final titleMatch = RegExp(r'^#\s+(.+)$', multiLine: true).firstMatch(raw); + if (titleMatch != null) { + title = titleMatch.group(1)!.trim(); + } + + // Extract tooltip text. + String tooltip = ''; + if (tooltipIdx >= 0 && detailsIdx > tooltipIdx) { + tooltip = + raw.substring(tooltipIdx + tooltipMarker.length, detailsIdx).trim(); + } + + // Parse detail blocks. + final detailBlocks = <_DetailBlock>[]; + if (detailsIdx >= 0) { + final detailsRaw = raw.substring(detailsIdx + detailsMarker.length); + detailBlocks.addAll(_parseDetails(detailsRaw)); + } + + return _HelpContent( + title: title, + tooltip: tooltip, + detailBlocks: detailBlocks, + ); + } + + /// Parse the details section into blocks. + static List<_DetailBlock> _parseDetails(String raw) { + final blocks = <_DetailBlock>[]; + final lines = raw.split('\n'); + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + final trimmed = line.trim(); + + // Skip empty lines + if (trimmed.isEmpty) { + continue; + } + + // ## Heading + if (trimmed.startsWith('## ')) { + blocks.add(_HeadingBlock(trimmed.substring(3).trim())); + continue; + } + + // Table separator row (|---|---|) — skip + if (RegExp(r'^\|[\s\-:|]+\|$').hasMatch(trimmed)) { + continue; + } + + // Table header row (| Key | Description |) — skip + if (trimmed.startsWith('|') && + trimmed.endsWith('|') && + i + 1 < lines.length && + RegExp(r'^\|[\s\-:|]+\|$').hasMatch(lines[i + 1].trim())) { + continue; + } + + // Table data row (| key | description |) + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + final cells = trimmed + .substring(1, trimmed.length - 1) // strip outer pipes + .split('|') + .map((c) => c.trim()) + .toList(); + if (cells.length >= 2) { + blocks.add(_EntryBlock( + key: _stripInlineCode(cells[0]), + description: cells[1], + )); + continue; + } + } + + // Plain paragraph text (collect consecutive non-empty lines) + final para = StringBuffer(trimmed); + while (i + 1 < lines.length && lines[i + 1].trim().isNotEmpty) { + final next = lines[i + 1].trim(); + // Stop at headings, table rows, or markers + if (next.startsWith('## ') || + next.startsWith('|') || + next.startsWith(' - - - - - rohd_devtools_extension - diff --git a/rohd_devtools_extension/web/manifest.json b/rohd_devtools_extension/web/manifest.json deleted file mode 100644 index 672668b8a..000000000 --- a/rohd_devtools_extension/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "rohd_devtools_extension", - "short_name": "rohd_devtools_extension", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/rohd_extension/.markdownlint.json b/rohd_extension/.markdownlint.json new file mode 100644 index 000000000..fe1bf1caa --- /dev/null +++ b/rohd_extension/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": { "tables": false, "code_blocks": false }, + "MD060": false +} diff --git a/rohd_extension/Makefile b/rohd_extension/Makefile new file mode 100644 index 000000000..911487565 --- /dev/null +++ b/rohd_extension/Makefile @@ -0,0 +1,108 @@ +# Makefile for the ROHD VS Code Extension (rohd_extension) +# +# Targets: +# compile - Compile TypeScript sources to out/ +# install - Compile and install to remote VS Code Server (default) +# install-local - Compile and install to local VS Code +# install-remote - Compile and install to remote VS Code Server +# clean - Remove compiled output and packaged .vsix files +# real-clean - clean + remove node_modules, lock files, .dart_tool +# help - Show this help + +ROOT := $(shell pwd) +TS_SOURCES := $(shell find $(ROOT)/src -name '*.ts' 2>/dev/null) + +# Read name/version from package.json +PKG_NAME := $(shell node -p "require('./package.json').name") +PKG_VERSION := $(shell node -p "require('./package.json').version") +PKG_PUBLISHER := $(shell node -p "require('./package.json').publisher") + +# VS Code Server extension directory (remote dev container) +REMOTE_EXT_DIR := $(HOME)/.vscode-server/extensions/$(PKG_PUBLISHER).$(PKG_NAME)-$(PKG_VERSION) +# Local VS Code extension directory +LOCAL_EXT_DIR := $(HOME)/.vscode/extensions/$(PKG_PUBLISHER).$(PKG_NAME)-$(PKG_VERSION) + +# Stamp file to track last successful compile +COMPILE_STAMP := out/.compile-stamp + +.PHONY: all help compile install install-local install-remote clean real-clean + +all: install + +help: + @echo "ROHD VS Code Extension - Build Targets" + @echo "" + @echo " compile - Compile TypeScript to out/" + @echo " install - Compile and install to remote server (default)" + @echo " install-local - Compile and install to local VS Code" + @echo " install-remote - Compile and install to remote VS Code Server" + @echo " clean - Remove compiled output and .vsix files" + @echo " real-clean - clean + node_modules, lock files, .dart_tool" + @echo "" + @echo "Extension: $(PKG_PUBLISHER).$(PKG_NAME) v$(PKG_VERSION)" + @echo "Remote: $(REMOTE_EXT_DIR)" + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + +# Install npm dependencies (including TypeScript) if missing or out of date +node_modules: package.json + @echo "Installing npm dependencies..." + @npm install + @touch node_modules + +# --------------------------------------------------------------------------- +# Compile +# --------------------------------------------------------------------------- + +$(COMPILE_STAMP): $(TS_SOURCES) tsconfig.json package.json node_modules + @echo "Compiling TypeScript..." + @npx tsc -p ./ + @touch $(COMPILE_STAMP) + +compile: $(COMPILE_STAMP) + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + +install-remote: compile + @if [ -d "$(REMOTE_EXT_DIR)" ]; then \ + echo "Installing to $(REMOTE_EXT_DIR)/out/..."; \ + cp out/*.js out/*.js.map "$(REMOTE_EXT_DIR)/out/"; \ + echo "Done. Reload the VS Code window to pick up changes."; \ + else \ + echo "ERROR: Extension not found at $(REMOTE_EXT_DIR)"; \ + echo "Install the extension first via VS Code, then use this target to update."; \ + exit 1; \ + fi + +install-local: compile + @if [ -d "$(LOCAL_EXT_DIR)" ]; then \ + echo "Installing to $(LOCAL_EXT_DIR)/out/..."; \ + cp out/*.js out/*.js.map "$(LOCAL_EXT_DIR)/out/"; \ + echo "Done. Reload the VS Code window to pick up changes."; \ + else \ + echo "ERROR: Extension not found at $(LOCAL_EXT_DIR)"; \ + echo "Install the extension first via VS Code, then use this target to update."; \ + exit 1; \ + fi + +install: install-remote + +# --------------------------------------------------------------------------- +# Clean +# --------------------------------------------------------------------------- + +clean: + @echo "Cleaning compiled output and .vsix files..." + @rm -rf out/ + @rm -f *.vsix + +real-clean: clean + @echo "Removing node_modules, lock files, and Dart build artifacts..." + @rm -rf node_modules/ + @rm -f package-lock.json + @rm -rf dart/.dart_tool/ + @rm -f dart/pubspec.lock diff --git a/rohd_extension/README.md b/rohd_extension/README.md new file mode 100644 index 000000000..0d4eaa5ef --- /dev/null +++ b/rohd_extension/README.md @@ -0,0 +1,358 @@ +# ROHD VS Code Extension + +A VS Code extension for the [ROHD](https://github.com/intel/rohd) hardware +design framework. It provides context-aware Dart code snippets, cross-probe +source navigation from ROHD viewers (schematic, waveform), and automatic +port-forwarded URI display for the Dart Tooling Daemon (DTD) and VM Service. + +## Features + +- **Context-aware ROHD snippets** — conditional constructs (`If`, `Iff`, + `Else`, `Case`, `CaseZ`) only appear when the cursor is inside + a `Combinational` or `Sequential` block. Module-body patterns + (`Sequential`, `Combinational`, `Pipeline`, current-module `FSM`, etc.) + only appear inside a module body. +- **Cross-probe source navigation** — click a signal or module in an ROHD + viewer and jump directly to the corresponding Dart source (FLC — **F**ile, + **L**ine, **C**olumn — crossprobing). +- **Debug adapter tracking** — registers a `DebugAdapterTrackerFactory` for + Dart sessions to automatically capture DTD and VM Service URIs with + port-forwarding awareness. +- **DTD bridge** — registers `rohd.goToSource` / `rohd.resolveFrames` + services on the Dart Tooling Daemon so the DevTools extension can navigate + the editor remotely. + +On activation the extension prints: + +```text +════════════════════════════════════════════════════════════ +ROHD 0.1.0: Extension loaded for FLC crossprobing. + +DTD: + URI: ws://127.0.0.1:44123/token + Fwd: ws://localhost:58201/token ← only shown if port differs +VM: + URI: ws://127.0.0.1:40699/TOKEN=/ws + Fwd: ws://localhost:61969/TOKEN=/ws ← only shown if port differs +════════════════════════════════════════════════════════════ +``` + +## Snippets + +Snippets are registered for Dart files. Open a `.dart` file and make sure +VS Code shows **Dart** as the language mode in the lower-right status bar. + +VS Code does not always expand snippets from `prefix` by default. If +typing `mod` only accepts a normal Dart completion, use one of these +flows: + +1. Type `mod`, press **Ctrl+Space** to open suggestions, select + **ROHD: Create Module**, then press **Enter** or **Tab**. +2. Enable tab expansion in your VS Code settings: + + ```json + { + "editor.tabCompletion": "onlySnippets", + "editor.snippetSuggestions": "top" + } + ``` + + With that setting, `mod` expands the ROHD module snippet directly + when no higher-priority editor action is using Tab. + +The extension also has context-aware completion snippets, such as showing +`If`, `Case`, and conditional assignment only inside `Combinational` or +`Sequential` blocks. These are controlled by the ROHD setting +`rohd.enableCompletions`, which is enabled by default. If it was disabled, +enable it manually in Settings or add this to `settings.json`: + +```json +{ + "rohd.enableCompletions": true +} +``` + +After changing extension settings or installing a new VSIX, reload the VS Code +window with **Developer: Reload Window**. + +### Static snippets + +These snippets are contributed by VS Code's snippet system. Context-aware +completions below narrow the ROHD-specific options by cursor location. + +| Prefix | Expands to | Description | +|--------|-----------|-------------| +| `mod`, `Module` | `class … extends Module { … }` | Module scaffold with `clk`, `reset`, `a`/`b` inputs, `depth`, `latchData`, `addInput`/`addOutput`, `definitionName`, and instance naming parameters | +| `sim`, `Simulator` | Clock, reset, `WaveDumper`, `Simulator.run()` | Simulation / testbench boilerplate | +| `fsmModule`, `FSMModule` | enum + `class extends Module` + `FiniteStateMachine` | Full standalone FSM module scaffold | +| `vf`, `tb`, `testbench` | `rohd_vf` testbench | Agent / Driver / Monitor / Sequencer template | + +### Context-aware — file scope + +These appear only at file/top level (not inside a function or class body). + +| Prefix | Expands to | Description | +|--------|-----------|-------------| +| `FSM`, `fsm` | enum + `class extends Module` + `FiniteStateMachine` | Full FSM scaffold at file level from context-aware completions | +| `Module`, `mod` | `class extends Module { addInput/addOutput … }` | Module scaffold with `clk`, `reset`, `a`/`b` inputs, `depth`, `latchData`, `definitionName`, and instance naming parameters | +| `Interface`, `intf` | enum + `class extends Interface` + `clone()` | Classic Interface with direction enum, `setPorts`, and `clone()` | +| `PairInterface`, `pairintf` | `class extends PairInterface { … clone() }` | PairInterface with provider/consumer roles | + +### Context-aware — module body scope + +These appear when the cursor is inside a `class … extends Module` body. + +| Prefix | Expands to | Description | +|--------|-----------|-------------| +| `FSM`, `fsm` | `FiniteStateMachine` for the current module | Inserts states and the FSM constructor call; inserts the enum before the enclosing class | +| `Pipeline`, `pipe` | `Pipeline(clk, stages: [(p) => […]])` | Pipelined datapath | +| `ReadyValidPipeline`, `rvpipe` | `ReadyValidPipeline(clk, stages: …, valid, ready)` | Pipeline with flow control | +| `Sequential`, `Seq`, `seq` | `Sequential(clk, [If(a, …)])` | `always_ff` block | +| `Combinational`, `Comb`, `comb` | `Combinational([…])` | `always_comb` block | +| `assign` | `out <= expr;` | Continuous assignment outside `_Always` blocks | + +### Context-aware — inside `Combinational` or `Sequential` + +These snippets only appear when the cursor is inside a `Combinational([…])` +or `Sequential(clk, […])` block, matching ROHD's requirement that +conditionals live inside an `_Always` block. + +| Prefix | Expands to | Description | +|--------|-----------|-------------| +| `If` | `If(cond, then: […], orElse: […])` | Inline if/else (most common) | +| `ifthen` | `If(cond, then: […])` | Simple conditional guard | +| `ifnested`, `iforelse` | `If(a, then: …, orElse: [If(b, …)])` | Nested if / else-if / else chain | +| `If.block`, `ifblock` | `If.block([Iff(…), ElseIf(…), Else(…)])` | Flat if/else-if/else block chain | +| `Iff`, `iff` | `If.block([Iff(…), ElseIf(…), Else(…)])` | Complete if/elseif/else block using `Iff` as the first clause | +| `Else`, `else` | `Else([…])` | Final clause in `If.block` | +| `Case` | `Case(expr, [CaseItem(…)], …)` | `case` / `unique case` / `priority case` | +| `CaseZ`, `casez` | `CaseZ(expr, [CaseItem(…)])` | Don't-care matching with `z` syntax | +| `CaseItem`, `caseitem` | `CaseItem(value, […])` | Single arm inside `Case`/`CaseZ` | +| `assign` | `out < expr,` | Conditional assignment (inside `_Always`) | + +> **Note:** bare `Iff` (two f's) is *not* a standalone conditional. It is the +> first entry in an `If.block([…])` chain. The `Iff` snippet expands to the +> full `If.block` form so it can be inserted directly inside `Sequential` or +> `Combinational`. + +### Context-aware — `test/` directory + +These appear only in Dart files under a `test/` directory. + +| Prefix | Expands to | Description | +|--------|-----------|-------------| +| `test`, `Test` | `test('description', () async { … })` | Async package:test case | +| `group`, `Group` | `group('description', () { test(…) })` | Test group with an async test inside | +| `tearDown`, `resetTest` | `tearDown(() async { await Simulator.reset(); })` | Reset ROHD simulation state between tests | +| `rohdtest`, `simtest`, `testsim` | Clock/reset/DUT build/`Simulator.run()` scaffold | ROHD simulation test flow based on common ROHD-HCL tests | + +## FLC Cross-Probing + +FLC (**F**ile, **L**ine, **C**olumn) data maps every signal and submodule +in the generated output back to the Dart source location where it was +constructed. The extension uses FLC data to navigate from a schematic or +waveform viewer directly to the ROHD Dart source. + +### FLC JSON Format (v6) + +An `.flc.json` file uses a shared ROHD source file table plus a per-module +trie of source frames. Each trie leaf is a compact symbol string for a +signal or submodule instance: + +```json +{ + "version": 6, + "files": [ + "lib/src/my_module.dart", + "lib/src/modules/gates.dart" + ], + "modules": { + "Top": { + "outputFiles": { + "sv": ["Top.sv"], + "sc": ["Top.cpp"] + }, + "tree": [ + [ + "0:6:20", + ["0:15:9", "a@sv:2:19,8:7;sc:44:5"], + ["0:16:15", "b@sv:3:20~originalB"], + ["1:22:3", "*inner@sv:7:1"] + ] + ] + } + } +} +``` + +- **`version`** — v6 is the current format. The extension can still parse + v5; other explicit versions are rejected. +- **`files`** — array of ROHD source paths, indexed by the first number in + each trie frame. +- **`outputFiles`** — map from output language to generated file list, for + example `"sv": ["Top.sv"]` or `"sc": ["Top.cpp"]`. The first file for + each language is the canonical lookup target. +- **`tree`** — list of trie root nodes. Each node starts with a source frame + string, then contains child nodes and/or symbol strings that share that + source-frame prefix. +- **`"0:15:9"`** — ROHD source frame: file index 0, line 15, column 9. + The column is optional and defaults to 1. +- **`"a@sv:2:19,8:7;sc:44:5"`** — signal `a`, with two SystemVerilog + output positions and one SystemC output position. Output-language groups + are separated by semicolons; entries within one language are separated by + commas. The language tag appears on the first entry in the group, so + `sv:2:19,8:7` means `sv:2:19` and `sv:8:7`. +- **`"b@sv:3:20~originalB"`** — canonical signal name `b`, original source + name `originalB`. Lookups may use either name. +- **`"*inner@sv:7:1"`** — submodule instance `inner`. Instance symbols are + prefixed with `*`; signal symbols are not. + +Source frames accumulate along the trie path from outermost to innermost. +When the extension opens ROHD source frames, it presents them innermost first +so the construction site closest to the signal or instance is selected first. + +## Commands + +| Command | Title | +|---------|-------| +| `rohd.openSourceLocation` | Go to Source Location | +| `rohd.openSourceLocations` | Go to Source Locations (multi-frame) | +| `rohd.nextSourceLocation` | Next Source Frame | +| `rohd.prevSourceLocation` | Previous Source Frame | +| `rohd.connectDtd` | Connect to Dart Tooling Daemon | +| `rohd.showForwardedUris` | Show Forwarded DTD/VM URIs | + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `rohd.enableCompletions` | `true` | Enable context-aware ROHD completions. Set `false` to disable the provider. | +| `rohd.dtdUri` | `""` | WebSocket URI of the Dart Tooling Daemon. Leave empty for auto-discovery. | + +## Prerequisites + +- **Node.js ≥ 18** (use nvm if the container ships an older version): + + ```bash + export PATH="$HOME/.nvm/versions/node/v20.19.6/bin:$PATH" + node --version # v18+ or v20+ + ``` + +- **npm** (comes with Node) + +## Build + +```bash +cd rohd_extension +npm install +npm run compile # produces out/extension.js +``` + +## Local Installation + +### Package as VSIX + +```bash +cd rohd_extension +yes | npx @vscode/vsce package --allow-missing-repository +``` + +This produces `rohd-0.1.0.vsix`. + +### Install + +```bash +code --install-extension rohd-0.1.0.vsix --force +``` + +Then reload the VS Code window (**Developer: Reload Window**). + +### One-liner (build + install) + +```bash +export PATH="$HOME/.nvm/versions/node/v20.19.6/bin:$PATH" \ + && cd rohd_extension \ + && npm install \ + && npm run compile \ + && rm -f *.vsix \ + && yes | npx @vscode/vsce package --allow-missing-repository \ + && code --install-extension rohd-0.1.0.vsix --force \ + && echo "Done — reload the VS Code window to activate." +``` + +## Remote Installation (Dev Containers / SSH) + +Extensions that interact with the Dart debug adapter must be installed on the +**remote** side (inside the container or on the SSH host). + +### Option 1: devcontainer.json (recommended) + +Place the extension source in your repo and build it on container creation: + +```jsonc +// .devcontainer/devcontainer.json +{ + "postCreateCommand": "cd rohd_extension && export PATH=\"$HOME/.nvm/versions/node/v20.19.6/bin:$PATH\" && npm install && npm run compile && rm -f *.vsix && yes | npx @vscode/vsce package --allow-missing-repository && code --install-extension rohd-0.1.0.vsix --force" +} +``` + +### Option 2: Pre-built VSIX + +Build the `.vsix` on your host or in CI, then install at container start: + +```jsonc +{ + "postStartCommand": "code --install-extension rohd_extension/rohd-0.1.0.vsix --force" +} +``` + +### Option 3: Install via CLI while connected + +```bash +code --install-extension rohd-0.1.0.vsix --force +``` + +The `code` CLI inside a remote session targets the VS Code Server +automatically. + +### Option 4: Install via VS Code UI + +1. Connect to the remote host / container. +2. Open Extensions (`Ctrl+Shift+X`). +3. Click `...` → **Install from VSIX...** and select the `.vsix` file. +4. Reload the window. + +### Option 5: Copy directly into `.vscode-server/extensions/` + +If the `code` CLI is not available (e.g. in a Dockerfile `RUN` step): + +```bash +mkdir -p ~/.vscode-server/extensions/rohd.rohd-0.1.0 +cp -r rohd_extension/{package.json,out,snippets,resources} \ + ~/.vscode-server/extensions/rohd.rohd-0.1.0/ +``` + +The directory name must follow the pattern `.-`. + +## File Structure + +```text +rohd_extension/ +├── package.json # Extension manifest +├── tsconfig.json # TypeScript configuration +├── src/ +│ ├── extension.ts # Entry point — activates all modules +│ ├── source_navigator.ts # Cross-probe → editor navigation +│ ├── dtd_bridge.ts # DTD JSON-RPC bridge +│ ├── debug_tracker.ts # Debug adapter tracker (DTD/VM URIs) +│ └── conditional_completions.ts # Context-aware conditional snippets +├── out/ # Compiled JS (generated) +├── snippets/ +│ └── rohd.json # ROHD Dart snippets +└── resources/ + └── rohd_icon.png # Extension icon +``` + +## License + +BSD-3-Clause — see the repository root LICENSE file. diff --git a/rohd_extension/dart/LICENSE b/rohd_extension/dart/LICENSE new file mode 100644 index 000000000..cfbbee995 --- /dev/null +++ b/rohd_extension/dart/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (C) 2021-2023 Intel Corporation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rohd_extension/dart/lib/dtd_service.dart b/rohd_extension/dart/lib/dtd_service.dart new file mode 100644 index 000000000..47a540b72 --- /dev/null +++ b/rohd_extension/dart/lib/dtd_service.dart @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// dtd_service.dart +// DTD service handler for receiving cross-probe source navigation +// requests from the ROHD DevTools extension. +// +// Registers a `rohd.goToSource` service on the Dart Tooling Daemon so +// that the DevTools extension can send resolved SourceFrame lists for +// navigation in the VS Code editor. +// +// 2026 April 27 +// Author: Desmond Kirkpatrick + +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'source_navigator.dart'; + +/// Callback invoked when the DTD service receives a goToSource request. +/// +/// The TS shell provides this callback to bridge DTD requests into +/// VS Code command execution. +typedef GoToSourceCallback = Future Function(List frames, + {int startIndex}); + +/// Manages the DTD connection and service registration for source +/// navigation. +class DtdService { + final GoToSourceCallback _onGoToSource; + Peer? _peer; + WebSocketChannel? _channel; + bool _disposed = false; + + /// Creates a DTD service that calls [onGoToSource] when a + /// `rohd.goToSource` request arrives. + DtdService({required GoToSourceCallback onGoToSource}) + : _onGoToSource = onGoToSource; + + /// Connect to the DTD at [uri] and register the `rohd.goToSource` + /// service. + /// + /// Returns `true` if connection and registration succeeded. + Future connect(String uri) async { + if (_disposed) return false; + + try { + _channel = WebSocketChannel.connect(Uri.parse(uri)); + await _channel!.ready; + _peer = Peer(_channel!.cast()); + + // Register the service method. + _peer!.registerMethod('rohd.goToSource', _handleGoToSource); + + // Start listening (non-blocking). + unawaited( + _peer!.listen().then((_) { + // Connection closed. + _peer = null; + }), + ); + + return true; + } on Exception catch (e) { + _peer = null; + _channel = null; + // ignore: avoid_print + print('[DtdService] Failed to connect to DTD at $uri: $e'); + return false; + } + } + + /// Whether the DTD connection is active. + bool get isConnected => _peer != null && !_peer!.isClosed; + + /// Disconnect from DTD and clean up. + Future dispose() async { + _disposed = true; + await _peer?.close(); + _peer = null; + await _channel?.sink.close(); + _channel = null; + } + + // --------------------------------------------------------------------------- + // RPC handler + // --------------------------------------------------------------------------- + + /// Handle an incoming `rohd.goToSource` request. + /// + /// Expected parameters: + /// ```json + /// { + /// "frames": [ + /// {"file": "lib/src/foo.dart", "line": 42, "col": 5, "type": "rohd"}, + /// {"file": "Foo.sv", "line": 10, "col": 1, "type": "sv"} + /// ], + /// "index": 0 // optional starting frame + /// } + /// ``` + Future> _handleGoToSource(Parameters params) async { + try { + final framesRaw = params['frames'].asList; + final startIndex = params['index'].asIntOr(0); + + final frames = framesRaw.map((raw) { + final map = raw as Map; + return SourceFrame.fromJson(map); + }).toList(); + + if (frames.isEmpty) { + return {'status': 'error', 'message': 'No frames provided'}; + } + + await _onGoToSource(frames, startIndex: startIndex); + return {'status': 'ok', 'navigated': frames.length}; + } on Exception catch (e) { + return {'status': 'error', 'message': e.toString()}; + } + } +} + +// --------------------------------------------------------------------------- +// Convenience: encode/decode frames for stdio-based communication +// --------------------------------------------------------------------------- + +/// Encode a goToSource request as a JSON string for stdio transport. +String encodeGoToSourceRequest(List frames, {int index = 0}) => + jsonEncode({ + 'method': 'rohd.goToSource', + 'frames': frames.map((f) => f.toJson()).toList(), + 'index': index, + }); + +/// Decode a goToSource request from a JSON string. +(List, int) decodeGoToSourceRequest(String json) { + final map = jsonDecode(json) as Map; + final framesRaw = map['frames'] as List; + final index = (map['index'] as int?) ?? 0; + final frames = framesRaw + .map((raw) => SourceFrame.fromJson(raw as Map)) + .toList(); + return (frames, index); +} diff --git a/rohd_extension/dart/lib/flc_data.dart b/rohd_extension/dart/lib/flc_data.dart new file mode 100644 index 000000000..010196836 --- /dev/null +++ b/rohd_extension/dart/lib/flc_data.dart @@ -0,0 +1,429 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// flc_data.dart +// FLC (File/Line/Column) data model for cross-probing from schematic +// signals to their ROHD Dart source locations. +// +// Parses trace data embedded in Yosys JSON module attributes under the +// `rohd.src_trace` key, as produced by `SourceTraceRegistry`. +// +// 2026 April +// Author: Desmond Kirkpatrick + +/// A single source location frame from an FLC trace. +class FlcFrame { + /// File path (package-relative, e.g. `lib/src/foo.dart`). + final String file; + + /// 1-based line number. + final int line; + + /// 1-based column number. + final int column; + + /// Frame type: `'rohd'` for ROHD Dart source, `'sv'` for SystemVerilog. + final String type; + + const FlcFrame({ + required this.file, + required this.line, + required this.column, + this.type = 'rohd', + }); + + @override + String toString() => '$file:$line:$column [$type]'; +} + +/// Trace entry for a single signal or instance — one or more stack frames +/// ordered innermost → outermost, plus output-language source locations. +class FlcEntry { + /// Stack frames for this signal/instance (ROHD Dart source). + final List frames; + + /// Output-language source locations (e.g. SystemVerilog, SystemC). + /// + /// Each frame's [FlcFrame.type] identifies the language (`'sv'`, `'sc'`, + /// etc.). Multiple frames per language are allowed (e.g. when a signal + /// appears in both a declaration and an assignment). + final List outputFrames; + + /// Original name before Namer disambiguation (e.g. `sum` before it + /// became `sum_0`). Null if the name was not renamed. + final String? origName; + + const FlcEntry({ + required this.frames, + this.outputFrames = const [], + this.origName, + }); + + /// First SystemVerilog output frame, or `null` if none. + /// + /// Convenience accessor for backward compatibility — equivalent to + /// `outputFrames.where((f) => f.type == 'sv').firstOrNull`. + FlcFrame? get svFrame => outputFrames.cast().firstWhere( + (f) => f!.type == 'sv', + orElse: () => null, + ); + + /// All frames: output-language frames first, then ROHD src frames. + List get allFrames => [...outputFrames, ...frames]; +} + +/// FLC data parsed from a v5 trie-based FLC hierarchy JSON file. +class FlcData { + /// Global file table (index → path). + final List files; + + /// Module name → signal name → FlcEntry. + final Map> _signals; + + /// Module name → instance name → FlcEntry. + final Map> _instances; + + FlcData._({ + required this.files, + required Map> signals, + required Map> instances, + }) : _signals = signals, + _instances = instances; + + /// Whether any FLC data was found. + bool get isEmpty => _signals.isEmpty && _instances.isEmpty; + + /// Parse FLC data from a v5/v6 trie-based hierarchy JSON. + /// + /// v5 and v6 share the trie structure; v6 adds multi-position support + /// (comma-separated entries per language) and list-per-language + /// outputFiles. Any other version is rejected and returns empty FLC data. + factory FlcData.fromJson(Map json) { + final version = json['version']; + if (version != null && version != 5 && version != 6) { + return FlcData._(files: [], signals: {}, instances: {}); + } + final files = + (json['files'] as List?)?.cast() ?? []; + final rawModules = json['modules']; + final modules = + rawModules is Map ? Map.from(rawModules) : null; + if (modules == null) { + return FlcData._(files: files, signals: {}, instances: {}); + } + + final signals = >{}; + final instances = >{}; + + for (final modEntry in modules.entries) { + final moduleName = modEntry.key; + final modMap = modEntry.value as Map?; + if (modMap == null) continue; + + final svFile = modMap['svFile'] as String?; + // Output files map. + // v5: {"sv": "Foo.sv", "sc": "Foo.h"} (string per language) + // v6: {"sv": ["Foo.sv"], "sc": ["Foo.h"]} (list per language) + // Falls back to legacy "svFile" field. For lookup we use the first + // file in each language list (the canonical output). + final rawOutputFiles = modMap['outputFiles']; + final outputFiles = { + if (svFile != null) 'sv': svFile, + }; + if (rawOutputFiles is Map) { + for (final e in rawOutputFiles.entries) { + final v = e.value; + if (v is String) { + outputFiles[e.key as String] = v; + } else if (v is List && v.isNotEmpty && v.first is String) { + outputFiles[e.key as String] = v.first as String; + } + } + } + final tree = modMap['tree'] as List?; + if (tree == null) continue; + + final modSignals = {}; + final modInstances = {}; + + /// Walk a trie node, collecting frames along the path. + /// Frames are accumulated outermost-first; we reverse at the leaf + /// to match the innermost-first convention of FlcEntry.frames. + void walkNode(List node, List path) { + if (node.isEmpty) return; + final frame = node[0] as String; + final currentPath = [...path, frame]; + + for (var i = 1; i < node.length; i++) { + final elem = node[i]; + if (elem is List) { + // Child trie node. + walkNode(elem.cast(), currentPath); + } else if (elem is String) { + // String-encoded leaf symbol. + final parsed = _parseSymbolString(elem); + final name = parsed.name; + final isInstance = parsed.isInstance; + final origName = parsed.origName; + + // Build ROHD source frames (reverse to innermost-first). + final rohdFrames = []; + for (final f in currentPath.reversed) { + final parts = f.split(':'); + if (parts.length < 2) continue; + final fi = int.tryParse(parts[0]); + if (fi == null || fi >= files.length) continue; + final line = int.tryParse(parts[1]) ?? 1; + final col = parts.length > 2 ? (int.tryParse(parts[2]) ?? 1) : 1; + rohdFrames.add( + FlcFrame(file: files[fi], line: line, column: col), + ); + } + + // Build output-language frames from parsed positions. + final outFrames = []; + for (final pos in parsed.outputPositions) { + final file = outputFiles[pos.type]; + if (file == null) continue; + outFrames.add( + FlcFrame( + file: file, + line: pos.line, + column: pos.column, + type: pos.type, + ), + ); + } + + if (rohdFrames.isNotEmpty || outFrames.isNotEmpty) { + final entry = FlcEntry( + frames: rohdFrames, + outputFrames: outFrames, + origName: origName, + ); + if (isInstance) { + modInstances[name] = entry; + } else { + modSignals[name] = entry; + } + } + } + } + } + + // tree is a list of root trie nodes. + for (final rootNode in tree) { + if (rootNode is List) { + walkNode(rootNode.cast(), []); + } + } + + if (modSignals.isNotEmpty) signals[moduleName] = modSignals; + if (modInstances.isNotEmpty) instances[moduleName] = modInstances; + } + + return FlcData._(files: files, signals: signals, instances: instances); + } + + /// Parse a v5 string-encoded symbol. + /// + /// Format: `[*]name[@positions][~origName]` + /// + /// Positions are semicolon-separated language groups; within each group + /// entries are comma-separated. Each entry is optionally prefixed with a + /// language tag (only the group's first entry carries the tag): + /// - `sv:L:C` — SystemVerilog at line L, column C + /// - `sc:L:C` — SystemC at line L, column C + /// - `L:C` — legacy shorthand, treated as `sv:L:C` + /// + /// Examples: + /// - `clk@2:13` (legacy single SV position) + /// - `clk@sv:2:13` (explicit SV position) + /// - `clk@sv:2:13;sc:10:5` (SV + SystemC) + /// - `clk@sv:2:13,5:7;sc:10:5` (v6: multi-entry within a language) + static _SymbolInfo _parseSymbolString(String s) { + final isInstance = s.startsWith('*'); + var rest = isInstance ? s.substring(1) : s; + + String? origName; + final tildeIdx = rest.indexOf('~'); + if (tildeIdx >= 0) { + origName = rest.substring(tildeIdx + 1); + rest = rest.substring(0, tildeIdx); + } + + final outputPositions = <_OutputPos>[]; + final atIdx = rest.indexOf('@'); + if (atIdx >= 0) { + final posStr = rest.substring(atIdx + 1); + rest = rest.substring(0, atIdx); + + for (final group in posStr.split(';')) { + if (group.isEmpty) continue; + // A group is `[lang:]entry(,entry)*` where each entry is `[F:]L:C`. + final entries = group.split(','); + String? groupLang; + for (var i = 0; i < entries.length; i++) { + var part = entries[i]; + if (part.isEmpty) continue; + // Only the first entry of a group may carry a language tag. + if (i == 0) { + final segments = part.split(':'); + final firstIsTag = + segments.length >= 3 && int.tryParse(segments[0]) == null; + if (firstIsTag) { + groupLang = segments[0]; + part = segments.sublist(1).join(':'); + } + } + final type = groupLang ?? 'sv'; + final segments = part.split(':'); + final lineStr = segments.length >= 2 + ? segments[segments.length - 2] + : segments[0]; + final colStr = + segments.length >= 2 ? segments[segments.length - 1] : null; + final line = int.tryParse(lineStr) ?? 1; + final column = colStr != null ? (int.tryParse(colStr) ?? 1) : 1; + outputPositions + .add(_OutputPos(type: type, line: line, column: column)); + } + } + } + + return _SymbolInfo( + name: rest, + isInstance: isInstance, + outputPositions: outputPositions, + origName: origName, + ); + } + + /// Create empty FLC data (no trace information available). + factory FlcData.empty() => FlcData._(files: [], signals: {}, instances: {}); + + /// Look up FLC frames for a signal in a given module. + /// + /// Returns null if no trace data exists for this signal. + /// Falls back to matching by [origName] if the canonical name isn't found. + List? lookupSignal(String moduleName, String signalName) => + lookupSignalEntry(moduleName, signalName)?.frames; + + /// Look up the full [FlcEntry] for a signal (includes SV frame if present). + FlcEntry? lookupSignalEntry(String moduleName, String signalName) { + final modSignals = _signals[moduleName]; + if (modSignals == null) return null; + + // Direct match. + final direct = modSignals[signalName]; + if (direct != null) return direct; + + // Fallback: search by origName. + for (final entry in modSignals.values) { + if (entry.origName != null && entry.origName == signalName) { + return entry; + } + } + return null; + } + + /// Look up FLC frames for an instance (submodule) in a given module. + List? lookupInstance(String moduleName, String instanceName) => + lookupInstanceEntry(moduleName, instanceName)?.frames; + + /// Look up the full [FlcEntry] for an instance (includes SV frame if present). + FlcEntry? lookupInstanceEntry(String moduleName, String instanceName) { + final modInstances = _instances[moduleName]; + if (modInstances == null) return null; + + final direct = modInstances[instanceName]; + if (direct != null) return direct; + + // Fallback: search by origName. + for (final entry in modInstances.values) { + if (entry.origName != null && entry.origName == instanceName) { + return entry; + } + } + return null; + } + + /// All module names that have FLC data. + Set get moduleNames => {..._signals.keys, ..._instances.keys}; + + /// Signal names recorded for [moduleName], or empty if none. + Set signalNamesFor(String moduleName) => + _signals[moduleName]?.keys.toSet() ?? {}; + + /// Instance names recorded for [moduleName], or empty if none. + Set instanceNamesFor(String moduleName) => + _instances[moduleName]?.keys.toSet() ?? {}; + + /// Reverse lookup: find all (moduleName, signalName, entry) tuples whose + /// ROHD source frames include the given [fileSuffix] and [line]. + /// + /// [fileSuffix] is matched against the end of each frame's file path + /// (e.g. `'serializer.dart'` matches `'lib/src/serialization/serializer.dart'`). + /// When [line] is non-null only frames on that exact line match. + List<({String module, String signal, FlcEntry entry})> lookupByRohdLine( + String fileSuffix, { + int? line, + }) { + final results = <({String module, String signal, FlcEntry entry})>[]; + for (final modEntry in _signals.entries) { + for (final sigEntry in modEntry.value.entries) { + for (final frame in sigEntry.value.frames) { + if (frame.file.endsWith(fileSuffix) && + (line == null || frame.line == line)) { + results.add(( + module: modEntry.key, + signal: sigEntry.key, + entry: sigEntry.value, + )); + break; // one match per signal is enough + } + } + } + } + for (final modEntry in _instances.entries) { + for (final instEntry in modEntry.value.entries) { + for (final frame in instEntry.value.frames) { + if (frame.file.endsWith(fileSuffix) && + (line == null || frame.line == line)) { + results.add(( + module: modEntry.key, + signal: instEntry.key, + entry: instEntry.value, + )); + break; + } + } + } + } + return results; + } +} + +/// Parsed v5 symbol string info. +class _SymbolInfo { + final String name; + final bool isInstance; + final List<_OutputPos> outputPositions; + final String? origName; + + const _SymbolInfo({ + required this.name, + required this.isInstance, + this.outputPositions = const [], + this.origName, + }); +} + +/// A parsed output-language position from a symbol string. +class _OutputPos { + final String type; // e.g. 'sv', 'sc' + final int line; + final int column; + + const _OutputPos({required this.type, required this.line, this.column = 1}); +} diff --git a/rohd_extension/dart/lib/rohd_source_navigator.dart b/rohd_extension/dart/lib/rohd_source_navigator.dart new file mode 100644 index 000000000..ea77ef85a --- /dev/null +++ b/rohd_extension/dart/lib/rohd_source_navigator.dart @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_source_navigator.dart +// Library exports for the ROHD source navigator Dart package. + +library; + +export 'dtd_service.dart'; +export 'flc_data.dart'; +export 'source_navigator.dart'; diff --git a/rohd_extension/dart/lib/source_navigator.dart b/rohd_extension/dart/lib/source_navigator.dart new file mode 100644 index 000000000..f6d96c972 --- /dev/null +++ b/rohd_extension/dart/lib/source_navigator.dart @@ -0,0 +1,217 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// source_navigator.dart +// Platform-independent source navigation logic — path normalisation, +// frame cycling, and candidate path generation. +// +// This is the Dart port of the core logic from source_navigator.ts. +// VS Code-specific APIs (editor, decorations, status bar) remain in the +// thin TypeScript shell. +// +// 2026 April 27 +// Author: Desmond Kirkpatrick + +/// A single source location frame. +class SourceFrame { + /// File path (package-relative, e.g. `lib/src/foo.dart`). + final String file; + + /// 1-based line number. + final int line; + + /// 1-based column number. + final int col; + + /// Optional description (e.g. function name from stack trace). + final String? desc; + + /// Frame type: `'sv'` for SystemVerilog, `'rohd'` for ROHD Dart source. + final String type; + + const SourceFrame({ + required this.file, + required this.line, + required this.col, + this.desc, + this.type = 'rohd', + }); + + /// Create from JSON map (as received over DTD). + factory SourceFrame.fromJson(Map json) => SourceFrame( + file: json['file'] as String, + line: json['line'] as int, + col: json['col'] as int, + desc: json['desc'] as String?, + type: (json['type'] as String?) ?? 'rohd', + ); + + /// Serialize to JSON for transmission. + Map toJson() => { + 'file': file, + 'line': line, + 'col': col, + if (desc != null) 'desc': desc, + 'type': type, + }; + + /// Short display name for status bar. + String get shortFile => file.split('/').last; + + /// Type tag for display: 'SV', 'ROHD', or 'Source'. + String get typeTag { + switch (type) { + case 'sv': + return 'SV'; + case 'rohd': + return 'ROHD'; + default: + return 'Source'; + } + } +} + +/// Manages frame cycling state for multi-frame source navigation. +class FrameCycler { + List _frames = []; + int _index = 0; + + /// The current list of frames. + List get frames => _frames; + + /// The current frame index. + int get index => _index; + + /// Whether there are multiple frames to cycle through. + bool get hasMultipleFrames => _frames.length > 1; + + /// Whether there are any frames at all. + bool get isEmpty => _frames.isEmpty; + + /// The current frame, or null if empty. + SourceFrame? get current => _frames.isEmpty ? null : _frames[_index]; + + /// Set frames for a single source location (no cycling). + void setSingle(SourceFrame frame) { + _frames = [frame]; + _index = 0; + } + + /// Set frames for multi-frame navigation. + void setMultiple(List frames, {int startIndex = 0}) { + _frames = frames; + _index = startIndex.clamp(0, frames.length - 1); + } + + /// Advance to the next frame (wrapping). + SourceFrame? next() { + if (_frames.isEmpty) return null; + _index = (_index + 1) % _frames.length; + return _frames[_index]; + } + + /// Go back to the previous frame (wrapping). + SourceFrame? prev() { + if (_frames.isEmpty) return null; + _index = (_index - 1 + _frames.length) % _frames.length; + return _frames[_index]; + } + + /// Clear all frames. + void clear() { + _frames = []; + _index = 0; + } + + /// Status bar text for the current frame. + /// + /// Format: `"TYPE 1/3: file.dart:42 desc"` + String get statusText { + if (_frames.isEmpty) return ''; + final f = _frames[_index]; + final desc = f.desc != null ? ' ${f.desc}' : ''; + return '${f.typeTag} ${_index + 1}/${_frames.length}: ' + '${f.shortFile}:${f.line}$desc'; + } + + /// Returns the first frame of each unique type (for opening + /// both ROHD and SV files simultaneously). + List firstOfEachType() { + final seen = {}; + final result = []; + for (final f in _frames) { + if (seen.add(f.type)) { + result.add(f); + } + } + return result; + } +} + +/// Normalize a file path by collapsing `.` and `..` segments. +/// +/// FLC paths from SourceTraceRegistry often contain `.dart_tool/../lib/...` +/// which needs collapsing before resolution. +String normalizePath(String filePath) { + final isAbsolute = filePath.startsWith('/'); + final parts = filePath.split('/'); + final resolved = []; + for (final part in parts) { + if (part == '.' || part.isEmpty) { + continue; + } else if (part == '..' && resolved.isNotEmpty && resolved.last != '..') { + resolved.removeLast(); + } else { + resolved.add(part); + } + } + final joined = resolved.join('/'); + return isAbsolute ? '/$joined' : joined; +} + +/// Generate candidate paths for a package-relative file path. +/// +/// Given a list of workspace root paths, produces candidates (in order): +/// 1. Normalized path relative to each workspace root +/// 2. Normalized path relative to parent directories (up to [parentLevels]) +/// 3. Absolute path (if applicable) +/// 4. Original un-normalized path (fallback) +/// +/// Returns relative candidate strings; the caller (TS shell) converts +/// them to URIs. +List resolveCandidatePaths( + String filePath, { + List workspaceRoots = const [], + int parentLevels = 4, +}) { + final normalized = normalizePath(filePath); + final candidates = []; + + for (final root in workspaceRoots) { + // Direct: workspace root + normalized path + candidates.add('$root/$normalized'); + + // Walk up parent directories. + var parent = root; + for (var i = 0; i < parentLevels; i++) { + final lastSlash = parent.lastIndexOf('/'); + if (lastSlash <= 0) break; + parent = parent.substring(0, lastSlash); + candidates.add('$parent/$normalized'); + } + } + + // Absolute path fallback. + if (normalized.startsWith('/')) { + candidates.add(normalized); + } + + // Try original un-normalized path if it differs. + if (normalized != filePath) { + for (final root in workspaceRoots) { + candidates.add('$root/$filePath'); + } + } + + return candidates; +} diff --git a/rohd_extension/dart/pubspec.yaml b/rohd_extension/dart/pubspec.yaml new file mode 100644 index 000000000..cd0129ffe --- /dev/null +++ b/rohd_extension/dart/pubspec.yaml @@ -0,0 +1,17 @@ +name: rohd_source_navigator +description: > + Dart implementation of the ROHD source navigator — path normalisation, + frame cycling, and DTD service for cross-probe source navigation. +version: 0.1.0 + +environment: + sdk: ^3.0.0 + +dependencies: + json_rpc_2: ^3.0.0 + stream_channel: ^2.1.0 + web_socket_channel: ^3.0.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/rohd_extension/dart/test/flc_data_test.dart b/rohd_extension/dart/test/flc_data_test.dart new file mode 100644 index 000000000..9c33e078f --- /dev/null +++ b/rohd_extension/dart/test/flc_data_test.dart @@ -0,0 +1,405 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// flc_data_test.dart +// Unit tests for FlcData model: v5 trie-based FLC JSON parsing and +// signal lookup. + +import 'package:rohd_source_navigator/flc_data.dart'; +import 'package:test/test.dart'; + +void main() { + group('FlcData.fromJson (v5 trie)', () { + test('parses single-frame signal', () { + final json = { + 'version': 5, + 'files': ['lib/src/foo.dart', 'lib/src/bar.dart'], + 'modules': { + 'TopModule': { + 'tree': [ + ['0:10:5', 'clk'], + [ + '0:20:3', + ['1:30:1', 'data'] + ], + ['1:50:7', '*sub0'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + expect(flc.isEmpty, isFalse); + expect(flc.files, ['lib/src/foo.dart', 'lib/src/bar.dart']); + expect(flc.moduleNames, contains('TopModule')); + + // Single-frame signal. + final clkFrames = flc.lookupSignal('TopModule', 'clk'); + expect(clkFrames, isNotNull); + expect(clkFrames!.length, 1); + expect(clkFrames[0].file, 'lib/src/foo.dart'); + expect(clkFrames[0].line, 10); + expect(clkFrames[0].column, 5); + + // Multi-frame signal (outermost frame first in trie, reversed to + // innermost-first in FlcEntry.frames). + final dataFrames = flc.lookupSignal('TopModule', 'data'); + expect(dataFrames, isNotNull); + expect(dataFrames!.length, 2); + // Innermost first after reversal. + expect(dataFrames[0].file, 'lib/src/bar.dart'); + expect(dataFrames[0].line, 30); + expect(dataFrames[1].file, 'lib/src/foo.dart'); + expect(dataFrames[1].line, 20); + + // Instance lookup. + final sub0Frames = flc.lookupInstance('TopModule', 'sub0'); + expect(sub0Frames, isNotNull); + expect(sub0Frames!.length, 1); + expect(sub0Frames[0].file, 'lib/src/bar.dart'); + expect(sub0Frames[0].line, 50); + }); + + test('parses signal with origName', () { + final json = { + 'version': 5, + 'files': ['lib/src/adder.dart'], + 'modules': { + 'Adder': { + 'tree': [ + ['0:42:5', 'sum_0~sum'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + + // Direct match by canonical name. + final directFrames = flc.lookupSignal('Adder', 'sum_0'); + expect(directFrames, isNotNull); + expect(directFrames![0].line, 42); + + // Fallback match by origName. + final origFrames = flc.lookupSignal('Adder', 'sum'); + expect(origFrames, isNotNull); + expect(origFrames![0].line, 42); + }); + + test('parses signal with SV position (legacy svFile)', () { + final json = { + 'version': 5, + 'files': ['lib/src/foo.dart'], + 'modules': { + 'FilterBank': { + 'svFile': 'FilterBank.sv', + 'tree': [ + ['0:868:11', 'clk@2:13'], + ['0:869:13', 'reset@3:13'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + expect(flc.isEmpty, isFalse); + + final clkEntry = flc.lookupSignalEntry('FilterBank', 'clk'); + expect(clkEntry, isNotNull); + + // SV frame via backward-compat getter. + expect(clkEntry!.svFrame, isNotNull); + expect(clkEntry.svFrame!.file, 'FilterBank.sv'); + expect(clkEntry.svFrame!.line, 2); + expect(clkEntry.svFrame!.column, 13); + expect(clkEntry.svFrame!.type, 'sv'); + + // outputFrames list. + expect(clkEntry.outputFrames.length, 1); + expect(clkEntry.outputFrames[0].type, 'sv'); + + // ROHD src frames. + expect(clkEntry.frames.length, 1); + expect(clkEntry.frames[0].file, 'lib/src/foo.dart'); + expect(clkEntry.frames[0].line, 868); + expect(clkEntry.frames[0].column, 11); + expect(clkEntry.frames[0].type, 'rohd'); + + // allFrames returns output frames first, then ROHD. + final allFrames = clkEntry.allFrames; + expect(allFrames.length, 2); + expect(allFrames[0].type, 'sv'); + expect(allFrames[1].type, 'rohd'); + }); + + test('parses signal with outputFiles map', () { + final json = { + 'version': 5, + 'files': ['lib/src/foo.dart'], + 'modules': { + 'FilterBank': { + 'outputFiles': {'sv': 'FilterBank.sv', 'sc': 'FilterBank.cpp'}, + 'tree': [ + ['0:868:11', 'clk@sv:2:13;sc:10:5'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final clkEntry = flc.lookupSignalEntry('FilterBank', 'clk'); + expect(clkEntry, isNotNull); + + // Two output frames. + expect(clkEntry!.outputFrames.length, 2); + expect(clkEntry.outputFrames[0].type, 'sv'); + expect(clkEntry.outputFrames[0].file, 'FilterBank.sv'); + expect(clkEntry.outputFrames[0].line, 2); + expect(clkEntry.outputFrames[0].column, 13); + expect(clkEntry.outputFrames[1].type, 'sc'); + expect(clkEntry.outputFrames[1].file, 'FilterBank.cpp'); + expect(clkEntry.outputFrames[1].line, 10); + expect(clkEntry.outputFrames[1].column, 5); + + // Backward-compat svFrame returns the first SV frame. + expect(clkEntry.svFrame, isNotNull); + expect(clkEntry.svFrame!.type, 'sv'); + expect(clkEntry.svFrame!.line, 2); + + // allFrames: output frames first (2), then ROHD (1). + expect(clkEntry.allFrames.length, 3); + }); + + test('parses multiple SV positions for same signal', () { + final json = { + 'version': 5, + 'files': ['lib/src/foo.dart'], + 'modules': { + 'Top': { + 'outputFiles': {'sv': 'Top.sv'}, + 'tree': [ + ['0:10:3', 'sig@sv:5:1;sv:20:3'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final entry = flc.lookupSignalEntry('Top', 'sig'); + expect(entry, isNotNull); + expect(entry!.outputFrames.length, 2); + expect(entry.outputFrames[0].line, 5); + expect(entry.outputFrames[1].line, 20); + // svFrame returns the first one. + expect(entry.svFrame!.line, 5); + }); + + test('sv frame is null when no svFile in module', () { + final json = { + 'version': 5, + 'files': ['lib/src/foo.dart'], + 'modules': { + 'Combinational': { + 'tree': [ + ['0:10:3', 'out@5:1'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final outEntry = flc.lookupSignalEntry('Combinational', 'out'); + expect(outEntry, isNotNull); + // No svFile/outputFiles -> output frames should be empty. + expect(outEntry!.svFrame, isNull); + expect(outEntry.outputFrames, isEmpty); + expect(outEntry.frames.length, 1); + expect(outEntry.frames[0].type, 'rohd'); + }); + + test('returns null for missing signals', () { + final json = { + 'version': 5, + 'files': ['lib/src/top.dart'], + 'modules': { + 'Top': { + 'tree': [ + ['0:1:1', 'a'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + expect(flc.lookupSignal('Top', 'nonexistent'), isNull); + expect(flc.lookupSignal('NonexistentModule', 'a'), isNull); + expect(flc.lookupInstance('Top', 'a'), isNull); + }); + + test('returns empty FlcData when modules is null', () { + final flc = FlcData.fromJson({'version': 5, 'files': []}); + expect(flc.isEmpty, isTrue); + expect(flc.files, isEmpty); + }); + + test('returns empty FlcData when modules is empty', () { + final flc = FlcData.fromJson({'version': 5, 'files': [], 'modules': {}}); + expect(flc.isEmpty, isTrue); + }); + + test('instance with SV position and origName', () { + final json = { + 'version': 5, + 'files': ['lib/src/top.dart'], + 'modules': { + 'Top': { + 'svFile': 'Top.sv', + 'tree': [ + ['0:42:3', '*sub0@20:5~origSub'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final entry = flc.lookupInstanceEntry('Top', 'sub0'); + expect(entry, isNotNull); + expect(entry!.frames.length, 1); + expect(entry.frames[0].line, 42); + expect(entry.svFrame, isNotNull); + expect(entry.svFrame!.line, 20); + expect(entry.outputFrames.length, 1); + expect(entry.outputFrames[0].type, 'sv'); + expect(entry.origName, 'origSub'); + + // Fallback by origName. + final byOrig = flc.lookupInstanceEntry('Top', 'origSub'); + expect(byOrig, isNotNull); + }); + }); + + group('FlcData.empty', () { + test('creates empty instance', () { + final flc = FlcData.empty(); + expect(flc.isEmpty, isTrue); + expect(flc.files, isEmpty); + expect(flc.moduleNames, isEmpty); + expect(flc.lookupSignal('any', 'thing'), isNull); + }); + }); + + group('FlcData multi-module', () { + test('handles multiple modules with shared files', () { + final json = { + 'version': 5, + 'files': ['lib/src/shared.dart', 'lib/src/b_only.dart'], + 'modules': { + 'ModA': { + 'tree': [ + ['0:10:1', 'a'], + ], + }, + 'ModB': { + 'tree': [ + ['1:20:1', 'b'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + expect( + flc.files, + containsAll(['lib/src/shared.dart', 'lib/src/b_only.dart']), + ); + + final aFrames = flc.lookupSignal('ModA', 'a'); + expect(aFrames, isNotNull); + expect(aFrames![0].file, 'lib/src/shared.dart'); + + final bFrames = flc.lookupSignal('ModB', 'b'); + expect(bFrames, isNotNull); + expect(bFrames![0].file, 'lib/src/b_only.dart'); + }); + }); + + group('FlcFrame edge cases', () { + test('handles frame with only file:line (no column)', () { + final json = { + 'version': 5, + 'files': ['lib/x.dart'], + 'modules': { + 'M': { + 'tree': [ + ['0:99', 's'], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final frames = flc.lookupSignal('M', 's'); + expect(frames, isNotNull); + expect(frames![0].line, 99); + expect(frames[0].column, 1); // defaults to 1 + }); + + test('skips malformed frame strings', () { + final json = { + 'version': 5, + 'files': ['lib/x.dart'], + 'modules': { + 'M': { + 'tree': [ + [ + 'bad', + ['0:10:5', 's'] + ], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + final frames = flc.lookupSignal('M', 's'); + expect(frames, isNotNull); + // 'bad' frame is skipped since file index parse fails. + expect(frames!.length, 1); + expect(frames[0].line, 10); + }); + + test('shared trie prefix produces correct frames', () { + final json = { + 'version': 5, + 'files': ['lib/src/top.dart', 'lib/src/inner.dart'], + 'modules': { + 'Top': { + 'tree': [ + [ + '0:100:1', // shared outer frame + ['1:10:5', 'sig1'], + ['1:20:3', 'sig2'], + ], + ], + }, + }, + }; + + final flc = FlcData.fromJson(json); + + // Both signals share the outer frame 0:100:1. + final sig1 = flc.lookupSignal('Top', 'sig1'); + expect(sig1, isNotNull); + expect(sig1!.length, 2); // inner + outer + // Innermost first after reversal. + expect(sig1[0].line, 10); + expect(sig1[1].line, 100); + + final sig2 = flc.lookupSignal('Top', 'sig2'); + expect(sig2, isNotNull); + expect(sig2!.length, 2); + expect(sig2[0].line, 20); + expect(sig2[1].line, 100); + }); + }); +} diff --git a/rohd_extension/package.json b/rohd_extension/package.json new file mode 100644 index 000000000..aff2df75b --- /dev/null +++ b/rohd_extension/package.json @@ -0,0 +1,102 @@ +{ + "name": "rohd", + "displayName": "ROHD", + "description": "ROHD extension for VS Code — snippets, cross-probe source navigation, and language support for the ROHD hardware design framework.", + "version": "0.1.0", + "publisher": "rohd", + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "https://github.com/intel/rohd" + }, + "icon": "resources/rohd_icon.png", + "engines": { + "vscode": "^1.80.0" + }, + "keywords": [ + "rohd", + "hardware", + "rtl", + "hdl", + "cross-probe" + ], + "categories": [ + "Snippets", + "Other" + ], + "activationEvents": [ + "onCommand:rohd.openSourceLocation", + "onCommand:rohd.openSourceLocations", + "onDebugResolve:dart", + "onDebug:dart", + "onStartupFinished" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "rohd.openSourceLocation", + "title": "ROHD: Go to Source Location" + }, + { + "command": "rohd.openSourceLocations", + "title": "ROHD: Go to Source Locations (multi-frame)" + }, + { + "command": "rohd.nextSourceLocation", + "title": "ROHD: Next Source Frame" + }, + { + "command": "rohd.prevSourceLocation", + "title": "ROHD: Previous Source Frame" + }, + { + "command": "rohd.connectDtd", + "title": "ROHD: Connect to Dart Tooling Daemon" + }, + { + "command": "rohd.showForwardedUris", + "title": "ROHD: Show Forwarded DTD/VM URIs" + } + ], + "configuration": { + "title": "ROHD", + "properties": { + "rohd.enableCompletions": { + "type": "boolean", + "default": true, + "description": "Enable context-aware ROHD code completions (If, Case, Pipeline, Module, etc.). Set to false to disable them." + }, + "rohd.dtdUri": { + "type": "string", + "default": "", + "description": "WebSocket URI of the Dart Tooling Daemon (ws://...). Leave empty for auto-discovery." + } + } + }, + "snippets": [ + { + "language": "dart", + "path": "./snippets/rohd.json" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, + "dependencies": { + "ws": "^8.14.0" + }, + "devDependencies": { + "@types/node": "^20.2.5", + "@types/vscode": "^1.80.0", + "@types/ws": "^8.5.5", + "@typescript-eslint/eslint-plugin": "^5.59.8", + "@typescript-eslint/parser": "^5.59.8", + "eslint": "^8.41.0", + "typescript": "^5.9.3" + } +} diff --git a/rohd_extension/resources/rohd_icon.png b/rohd_extension/resources/rohd_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a8f1faecdde32ff3c2a4b01f5ca74a58c88c2726 GIT binary patch literal 5225 zcmZ`-cQoAFxBn_bMvXCQ5M}gU61|3)G3qFzchQ6BBu0qdOGGE41ks}=%7`JsB}DW- z*VTiB5cSRdy|><4?~k{><*e^MyPfa(?7h!935NRG)D)}~002-U;F>5x+xPEti;VC_ zEqaj?8d6tvJ#_$RN~64V01>`9o!}@v00`p)fXEjBa84jaZUI1`1OV*V1Asy<05JI$ zb{Q!VI>;Q6+M2-4zgKB@RThCl9)Qr(BHttN-QQ9cnWV7vN3r- zotS9Z4#^f7J@i53`ElB--)Bh*@_SlfdBnkZ%5RT} z1_dmm=FAhn9Yt0ih#)FI`?!uD-x6;46KfND6CfRtKrILLFZd5XrK<>Vs?!J_k-Teg zyG|qXef}`Vq(&6=SspX<-DC^B75hxxhZR2C8c`=2QOdsQETb~Kh$WL{Jwcdxbcqv~ zWBFK*?~5iCby_lw!>>fj9J8=h#~?SbO6sSAo(b3-Ys;aK2rCCm9sX=)o%FJN`;J~_ zon-h#CeMvAl_ln*+n0=8Mp5}m_L(ACJPDHxSBI%?21nFVLC+dYP#URpIdVv}XSRWD zP{z#7wvQYsST7w@^CR+JuVqa9g*(0DN3#rDp08nL%3hBxjyBqxJ5J#TpB7b>q%hUC z^PaITN4?Zr3CZ-miK($TqkMYng5YS4hp#ofk)>oPTvGy-DKk86=HZHWd&+!a7TT;4 zm2xI9Hqce68d<)OJp|zAk{c4;M|Z2A>NaNOM%l!wW;Z=Rkm&LOT-e}4OKbHF$Y}qA z0qFLpMkl|q@^GqzW$f^JFMU&%xxKP3C`)ibC!PgT+wq2b`6jXEN81*Sw@|9ZoL?5U zG8^-g?Wv9~-k_lRp{EK%$$+{kO>&lVxL^uTZxYfq?BhNd9U-Oie2AMDp8qJtvD<^s z%je@Y_Dtj<9GrRWssI*FgE~d=3~+XBhr=`7rI(WHR2dz{v@=E3CwoU>E=TGiz3E(t z^US<|_O3IwD0mTeD6Pq`HUP1LiUEG*_awmb#4Ori+cJV`lkTX<@^9q;q6$Hjsh>p* zE8+ve74#_51M;Dq|CgcKv0%!~7`Dq6Ulrf&urTmwk@u(72o(dyY&krX-Hc(y=Y7T7 z+4J?DX(mit>dx>Cl>&*l8us;B>1=nicVOK8E)09&>$=N7d`*=6M|~@%izBS>7%KSC z_@G58C8@A(0k|>AnJPdfNiBz>6B<3t;emCB7W|~Ew&3Z6roQbiQ7rR=S;}$x zmp47nW$|2}c;2*haca2!hk*jTEGWApAWfTw4fo^FA6$-J<_^#Se(+%94$UOtx@{6j zlM6ZOn^>ntdqb_QLSRuCOJ=lt?A^>e%i3}zX9VnfapC$9UlBwdC8R!mKKVVBInB4l zvFO96qbxA6f-rEJvFh&_Cc~SXOQPlJD;30=EoM{&B6xRy4T*~KZel(_ebO(}n+W|^ zLjx{Q&(1h0FAaZ0M4=TkU@g2JeXdVr+^p82J zdpa>7&$MPqpVBwbEp&>xpGQ~?r!zxWQt0EfA0BKCNhC%*CW<|zpj$y%NL!!tM$k75 zZs@JJC9(83g(wE?4b>Z}QZ}-Da(r;G`KGzzB9n9ll6h6LUEnR*f}<%L+Wy2IeQp7i zjrTN|zYWa@gPmo}e!Xa}^l8J1+^*d*sOV)JIk$sR&SL9k{ks?hSMK^&KS$$lD zp55o)5(3119NeGxUkX{*vGHR^4qGaH-s9L3=}pfdH3jMrfFu4iezbwzBYjn4f?WsP zHkR%dL+g32Q8`=+20p|OyppZaj!M!B7T}##NYe89z8>KQA~OQosD|~82I@@I5m9!D z7HR&zDR9k$lNomI5$}{-DmQuq9HrY$WQRCYf8}T5k08ahDMH3EWf0G~($UTMQJu$z zw&olqjAJ*4jNziP(TMkiBGM0z`eB8f)%CPo>OwwrU6_YY)Xk zvg0=}&zF-VknjF_O4lxP#iAFKVPl_~+kdIJH-C(q`iqcvE&Z^QCyvuQFgD&7=CW@{ zd{ooSyvmoSo7B*xoH=`T+_2g1l}{m68eIKkmnKy6E6*3gLcqT?cfZXm=-NOc(^o6F zhy6<`#VgTJn{q{*#+sCoR1;bW;hfc)Sr3*78XsqB+PesN&HFF-)-Cf~O)|TvsNJIzjzZVFqVIUKi5E+T z5>RV#QnQe3%jXz0=GjzJih`vUMPncb7fR&r)N#ylyd8cxf4s@idA~IgYVXLArs~>g z^*m}pEA>Iq=!CWN&Wmz@UK$2bw3PL&eg0QR`9J=nll^J~Q2QspaU}UY@d|<|B=yFa z_inqke1d&{kJ9_k+N{!?t~7q|2y#E?8zQ7qr+17W{+dDT7Pn8^7)~#=1Dgrq!e*dKDWQ#g;B}F0Gj*fkzO+1gC8CBRs<@Z>tjQ^*YF=de4#D;G z2n>&wMzr?T8Q!xL5+cgl+P8>6xpj!V`ej0*(SU%ff_g8JWmc(^CTrMBzQTnVt3 zhg^scdqmPV!U_JwvqOCr4RDWqG*k>OcM~;{#NZ7+UjmZGia7E9Dj8NF{33cP)vPC zJGuWjlRSoiJka(JUef9`744d{9D^Kx9om|8ob_zowC59MHdeEAR@y`1I&3i>gVQGD zLA0Sw@auGYl83lYH}|^{JT`)Dug{;+j@{#FrkoP_)!3U`^an!odj5wQ*S9dEogduN4q730S@{r&QA+!))Z0&-&_U%h)M z;*qg>8uDtEwK}!>%lD9m<$WMKT?!8J@46;ineqW+|q|O#ScMtAc9TF*C zIOrfYh`RJzl)Q+Ama5Jo_dISWPT+@>A8XDIO#aZ>rk12Cn%QL#AJZn`IDawZ8fTI6 z&;K5rJ6%oUu732V!|9k^Pz}=rhac*U)~ZL{P0X<`+t2)QTT*Mp_pgqUeIzRK+KFyC zPS!qx8gi-j!T__9!+_UY_r7`3^tJT~p~7EWuknMn&_Rsy-Tc+sQ5)P2*HF6RqlZcc zr2D3AAj(+9N(uRgACys@P~;@~HmaLkbDzQ6>lKgMKkV>=c5CEKU-9&6s2y zRh>ah#pv%>m|J6=4e=^~+Anx9CU6KN?P|4Rznk=$F{lQ+Zg1`m59jm1&Vb)=Fdx6` zb3Rudl{5^LcHf+4Cu-fZ=H1}@2T!2`&$B;!Be7y@n@sM}~gZ+=EqtccxmO?-A>biFVZOMCUknJ$xZki2inx zhB!3ZQzg`s22hU&4=A@V#l!NJ5N>i2276l@^)r2zY}<+ZyA-cL2C=UG_h-KnIxGW> zBP{UpskcK1Y591Wc|dNtsNgMb$+X_G!Efx(4e)9xVpOD=gc$S>?1aX=gjL^ic~|GKJ$R17_3%tfHW$T2P1=WS0gL{lPi+{ofjEc-Lf;Xy<&KJI3 zc5F*y6jk5Q3cZMdmc~FK@8}EWo>_O_v1St(B;^S(An2$dQ2XtY!U_WUD%k`c#if>7 zLCOKqN|Vvw4Tp942C`;p?~MtRD=f9+>%F0SC9dYl%Bd&^!NL1k#P19J0+sCehv}xj zTyqD#ohNCvnc;`b)(+Ha; zQR2;>(rc+$rxW-}d`vVafqN4QS>)4ryE`l@){^B0UVaR~-~`Cs!4-7YFRn}d(n7yI z;3ZHZCy%#YI?fDrp_AsgvF`cR8fe7E4%A*v0O8w|zFNak7^Gq=+~&R0O1gV=k;ef8 zF>|(MS}i7})VktBn#>wKX?e}X`*E(C6cYB?u(Mcm(VlMdsv;hk9A)_Lx4_I&neSXO z!B9GxSyDpIDl?sCyK51D*Y8Ugr_-M~3X zIrj7A0Eupzf6`EH%$t~yiZ&rMA(S1QR za)AxTUW=tr;83uXN8endzMDwFM zNo#Q`Up8m;33yC3%3M_}TmE_w7IHq6m{Upvj^K|X8gFJdeq(w|VkQzz!z`l%DHPTy zaAh-YVOP?DA36)YS~dH~z>k+T{?jQ$I*S~LKh6I$Tlf4I4QYkWR0*S1jlSn?c|DF8 zgEe2FSN3(E)5Bj1j?z#Qb|hl~wr4bSkPD|HzPah6*cTH1{AcYCxK{$dlVW-;28!8I zgA+`;yUxI=1N+fkIJcuiQ7Bze105H2_qvD3r}pZ{cV>7E6vOuxs^Y~a)akDlgh=g2 zNIpzYk>>KNY$aUpKRSAtzjfg1WaL+4r3acbDot#1H8K0dtIQUaTl=K5SIa*pV z=$AnBa37__L$3aavlnG*k7+v*CWUfnWe8hx+A0`-qc=v(>vC=IG3DmN(B_gA>DO+x z7?Yi(hbDmtkw?{$E?IB3CWlTK`+b}@9Dn5)UA=Vw! zkTgFa4gy5xUMCG+a}v_hAIHIJ>Ev3~^Pt5!Jd(LqBlsgl z?bnoDnr_qtA-KO>3AkiGvsFs=41Wqv(n21FogfZ9E;&K)HXSakGWTRN`iqPU2j_IC zzF``v>QIwBwSxSsB;Vyb4$Q0!UspQXJ5?Xa=LN!ap-|=25gK^mQKG z_x(|%^>K1x$}mO@1m|-hRF$Ew2QvvjKK~>WbLxTr9a@`62(`@pHIxy!o~eRqs3qFG zT#@>N39mXux^4n$<)SJ?K4PlRWt531ys8!+1?9|>;sxj6E4aC?iaG2C*_v#=Z6~6V zrRlf`W4#x4L;3h#g(UX7quAA7Ww(9AUFkRDaqn728!47VBs&moV_MuRTrApLSe>KW zDptCtww1CBTk+{p z>;pYayq7(7~!CdH$clgz}X?dMZxKb3!wp|B&4K8CGLw#NSR1VDBP1$kdYLY zkWi42*tqp9=KlzI`#8Hl3jO~DvQ2UF1OarIiFp9ZA(+$miO(Z|D?L*XC@#pY%ns4*gyd!Jd|Qil=M9G$Sf!cJ*Q@*%qM3Oo)#0%k=d!w1ET|M h1LPx-@KPNyV26n=;HSpDmjn|4LQ7w>Uc)}>zX0c)l=J`q literal 0 HcmV?d00001 diff --git a/rohd_extension/snippets/rohd.json b/rohd_extension/snippets/rohd.json new file mode 100644 index 000000000..5c3c9ae73 --- /dev/null +++ b/rohd_extension/snippets/rohd.json @@ -0,0 +1,385 @@ +{ + "ROHD: Create Module": { + "prefix": ["mod", "module", "Mod", "Module"], + "body": [ + "/// A ROHD module with two data inputs and one output.", + "class ${1:MyModule} extends Module {", + "\t/// The output of this module.", + "\tLogic get ${2:out} => output('${2:out}');", + "", + "\t/// The clock input.", + "\tLogic get clk => input('clk');", + "", + "\t/// The reset input.", + "\tLogic get reset => input('reset');", + "", + "\t/// The configured data depth.", + "\tfinal int depth;", + "", + "\t/// Whether data should be latched.", + "\tfinal bool latchData;", + "", + "\t/// Constructs a ${1:MyModule}.", + "\t${1:MyModule}(", + "\t\tLogic ${3:clk},", + "\t\tLogic ${4:reset},", + "\t\tLogic ${5:a},", + "\t\tLogic ${6:b}, {", + "\t\tthis.depth = ${7:1},", + "\t\tthis.latchData = ${8:false},", + "\t\tString? definitionName,", + "\t\tsuper.name = '${9:my_module}',", + "\t\tsuper.reserveName,", + "\t\tsuper.reserveDefinitionName,", + "\t}) : super(definitionName: definitionName ?? '${1:MyModule}') {", + "\t\t// Register inputs and outputs of the module.", + "\t\t${3:clk} = addInput('clk', ${3:clk});", + "\t\t${4:reset} = addInput('reset', ${4:reset});", + "\t\t${5:a} = addInput('${5:a}', ${5:a}, width: ${5:a}.width);", + "\t\t${6:b} = addInput('${6:b}', ${6:b}, width: ${5:a}.width);", + "\t\tfinal ${2:out} = addOutput('${2:out}', width: ${5:a}.width);", + "", + "\t\t${2:out} <= ${5:a};", + "\t}", + "}" + ], + "description": "ROHD: Create Module with clk, reset, depth, latchData, definitionName, and instance naming parameters." + }, + "ROHD: Generate Simulation snippet.": { + "prefix": ["sim", "Simulator", "simulation"], + "body": [ + "final clk = SimpleClockGenerator(10).clk;", + "final reset = Logic(name: 'reset');", + "WaveDumper(module, outputPath: 'wavedumpername.vcd');", + "Simulator.setMaxSimTime(100);", + "unawaited(Simulator.run());", + "", + "// reset flow", + "reset.inject(0);", + "await clk.nextNegedge;", + "reset.inject(1);", + "await clk.nextNegedge;", + "await clk.nextNegedge;", + "await clk.nextNegedge;", + "reset.inject(0);", + "await clk.nextNegedge;", + "", + "// Stimulus and checking here", + "await clk.nextNegedge;", + "", + "Simulator.endSimulation();", + "await Simulator.simulationEnded;" + ], + "description": "Add simulation snippet. Normally used in performing testing." + }, + "ROHD: Finite State Machine Module (FSM)": { + "prefix": ["fsmModule", "FSMModule", "fsm_module"], + "body": [ + "// Change idle, active and done to your respective states", + "// Keep the enum at file scope, outside the Module class.", + "/// FSM states for SampleFSMModule.", + "enum FSMState {", + "\t/// Waiting for work.", + "\t${1:idle},", + "", + "\t/// Processing an active transaction.", + "\t${2:active},", + "", + "\t/// Completing the transaction.", + "\t${3:done},", + "}", + "", + "class SampleFSMModule extends Module {", + "\t// Modified Logics inputs based on your needs", + "\tSampleFSMModule(Logic clk, Logic reset, Logic a, Logic b)", + "\t\t\t: super(name: 'fsm_module_name') {", + "\t\tclk = addInput('clk', clk);", + "\t\treset = addInput(reset.name, reset);", + "\t\ta = addInput(a.name, a);\n", + "\t\t// The output of the fsm, modified this based on your needs", + "\t\tfinal c = addOutput('output_pin');\n", + "\t\t// Below is the example of the state transition, ", + "\t\t// modified according to your needs", + "\t\tfinal states = [", + "\t\t\t// IDLE", + "\t\t\tState(", + "\t\t\t\tFSMState.${1:idle},", + "\t\t\t\tevents: {", + "\t\t\t\t\tb: FSMState.${2:active},", + "\t\t\t\t},", + "\t\t\t\tactions: [", + "\t\t\t\t\tc < 0,", + "\t\t\t\t],", + "\t\t\t),", + "\t\t\t// ACTIVE", + "\t\t\tState(", + "\t\t\t\tFSMState.${2:active},", + "\t\t\t\tevents: {", + "\t\t\t\t\ta: FSMState.${3:done},", + "\t\t\t\t},", + "\t\t\t\tactions: [", + "\t\t\t\t\tc < 0,", + "\t\t\t\t],", + "\t\t\t),", + "\t\t\t// DONE", + "\t\t\tState(", + "\t\t\t\tFSMState.${3:done},", + "\t\t\t\tevents: {", + "\t\t\t\t\tConst(1): FSMState.${1:idle},", + "\t\t\t\t},", + "\t\t\t\tactions: [", + "\t\t\t\t\tc < 1,", + "\t\t\t\t],", + "\t\t\t)", + "\t\t];\n", + "\t\tFiniteStateMachine(clk, reset, FSMState.${1:idle}, states);", + "\t}", + "}" + ], + "description": "Full FSM Module scaffold. Use fsm inside an existing Module. The enum belongs at file scope, outside the class." + }, + "ROHD Testbench": { + "prefix": ["vf", "tb", "testbench"], + "body": [ + "import 'dart:async';", + "import 'dart:collection';", + "import 'package:logging/logging.dart';", + "import 'package:rohd/rohd.dart';", + "import 'package:rohd_vf/rohd_vf.dart';", + "", + "/// Main function entry point to execute this testbench.", + "Future main({Level loggerLevel = Level.FINER}) async {", + " // Set the logger level", + " Logger.root.level = loggerLevel;", + "", + " // Create the testbench", + " final tb = TopTB();", + "", + " // Build the DUT", + " await tb.dut.build();", + "", + " // Attach a waveform dumper to the DUT", + " WaveDumper(tb.dut);", + "", + " // Set a maximum simulation time so it doesn't run forever", + " Simulator.setMaxSimTime(300);", + "", + " // Create and start the test!", + " final test = DUTTest(tb.intf);", + " await test.start();", + "}", + "", + "class TopTB {", + " // TODO: Create an instance of the DUT (The Module you want to test)", + " late final ${1:dutModule} dut;", + "", + " // TODO: Build an instance of the interface for the DUT", + " final ${2:dutInterface} intf = ${2:dutInterface}();", + "", + " TopTB() {", + " // TODO(Optional): Initialized your pin here", + " // Example: intf.clk <= SimpleClockGenerator(10).clk;", + "", + " // Create the DUT, passing it our interface", + " dut = ${1:dutModule}(intf);", + " }", + "}", + "// A Test is like a top-level testing entity that contains the top testbench", + "// Env and kicks off Sequences. Only one Test should be running at a time. The", + "// Test also contains a central Random object to be used for randomization in", + "// a reproducible way.", + "class DUTTest extends Test {", + " // Interface of DUT", + " final ${2:dutInterface} intf;", + "", + " late final DUTEnv env;", + "", + " late final DUTSequencer _dutSequencer;", + "", + " DUTTest(this.intf, {String name = 'dutTest'}) : super(name) {", + " env = DUTEnv(intf, this);", + " _dutSequencer = env.agent.sequencer;", + " }", + "", + "", + " @override", + " Future run(Phase phase) async {", + " unawaited(super.run(phase));", + " final obj = phase.raiseObjection('dut_test');", + "", + " logger.info('Running the test...');", + "", + " // TODO: Register your test action with Simulator here, you can", + " // change the Simulation time.", + " // Simulator.registerAction(1, () {", + " // Example: intf.reset.put(0);", + " // });", + "", + " // TODO: Add sequenceItem to the sequencer for initialization", + " // Example: _dutSequencer.add(DUTSeqItem(false));", + "", + " // TODO: Kick start the Sequencer with n number of DUTSequence repetition", + " // Example: await _dutSequencer.start(DUTSequence(5));", + "", + " logger.info('Done adding stimulus to the sequencer');", + "", + " obj.drop();", + " }", + "}", + "", + "class DUTEnv extends Env {", + " // Interface of DUT", + " final ${2:dutInterface} intf;", + "", + " /// The agent that communicates with the DUT.", + " late final DUTAgent agent;", + "", + "", + " DUTEnv(this.intf, Component parent, {String name = 'dutEnv'})", + " : super(name, parent) {", + " agent = DUTAgent(intf, this);", + " }", + "", + " @override", + " Future run(Phase phase) async {", + " unawaited(super.run(phase));", + "", + " // TODO: You can add a listener to the output of the monitor for some logging", + " // Example:", + " // agent.valueMonitor.stream.listen((event) {", + " // logger.finer('');", + " // });", + " }", + "}", + "", + "/// An agent to bundle the sequencer, driver, and monitors for one DUT.", + "class DUTAgent extends Agent {", + " final ${2:dutInterface} intf;", + " late final DUTSequencer sequencer;", + " late final DUTDriver driver;", + " late final DUTValueMonitor valueMonitor;", + "", + " DUTAgent(this.intf, Component parent, {String name = 'dutAgent'})", + " : super(name, parent) {", + " sequencer = DUTSequencer(this);", + " driver = DUTDriver(intf, sequencer, this);", + " valueMonitor = DUTValueMonitor(intf, this);", + " }", + "}", + "", + "/// A basic [Sequencer] for the DUT.", + "class DUTSequencer extends Sequencer {", + " DUTSequencer(Component parent, {String name = 'dutSequencer'})", + " : super(name, parent);", + "}", + "", + "// A driver responisble for converting SequenceItem into signal transitions", + "// on a hardware interface.", + "class DUTDriver extends Driver {", + " final ${2:dutInterface} intf;", + "", + " final Queue _pendingItems = Queue();", + "", + " Objection? _driverObjection;", + "", + " DUTDriver(this.intf, DUTSequencer sequencer, Component parent,", + " {String name = 'dutDriver'})", + " : super(name, parent, sequencer: sequencer);", + "", + " @override", + " Future run(Phase phase) async {", + " unawaited(super.run(phase));", + "", + " // Listen to new items coming from the sequencer, and add them to a queue", + " sequencer.stream.listen((newItem) {", + " _driverObjection ??= phase.raiseObjection('dut_driver')", + " ..dropped.then((value) => logger.fine('Driver objection dropped'));", + " _pendingItems.add(newItem);", + " });", + "", + " // Every clock negative edge, drive the next pending item if it exists", + " intf.clk.negedge.listen((args) {", + " if (_pendingItems.isNotEmpty) {", + " final nextItem = _pendingItems.removeFirst();", + " drive(nextItem);", + " if (_pendingItems.isEmpty) {", + " _driverObjection?.drop();", + " _driverObjection = null;", + " }", + " }", + " });", + " }", + "", + " // Translate a SequenceItem into pin wiggles", + " // TODO: Map sequence item to respective pin", + " void drive(DUTSeqItem? item) {", + " // Example:", + " // if (item == null) {", + " // intf.en.inject(0);", + " // } else {", + " // intf.en.inject(item.en);", + " // }", + " }", + "}", + "", + "/// A monitor is responsible for watching an interface and reporting out", + "/// interesting events onto an output stream.", + "class DUTValueMonitor extends Monitor {", + " final ${2:dutInterface} intf;", + "", + " DUTValueMonitor(this.intf, Component parent,", + " {String name = 'dutValueMonitor'})", + " : super(name, parent);", + "", + " @override", + " Future run(Phase phase) async {", + " unawaited(super.run(phase));", + " await intf.reset.nextNegedge;", + "", + " intf.clk.posedge.listen((event) {", + "", + " // TODO: Add the output pin that you want to monitor here", + " // Example: add(intf.val.value);", + " });", + " }", + "}", + "", + "", + "// A Sequence is a modular object which has instructions for how to send", + "// SequenceItems to a Sequencer. A typical use case would be sending a", + "// collection of SequenceItems in a specific order.", + "class DUTSequence extends Sequence {", + " final int numRepeat;", + "", + " DUTSequence(this.numRepeat, {String name = 'dutSequence'}) : super(name);", + "", + " @override", + " Future body(Sequencer sequencer) async {", + " final dutSequencer = sequencer as DUTSequencer;", + " for (var i = 0; i < numRepeat; i++) {", + " // TODO: Add sequenceItem to the Sequence that we want to send to test", + " // Example: dutSequencer", + " // ..add(DUTSeqItem(true))", + " // ..add(DUTSeqItem(false));", + " }", + " }", + "}", + "", + "// A SequenceItem represents a collection of information to transmit across", + "// an interface. A typical use case would be an object representing", + "// a transaction to be driven over a standardized hardware interface.", + "class DUTSeqItem extends SequenceItem {", + " // TODO: Add your sequence Item", + " // Example: final bool _enable;", + "", + " // TODO: Register your input variable to the constructor", + " // Example: DUTSeqItem(this._enable);", + "", + " // TODO: Create a getter for monitoring purposes", + " // int get en => _enable ? 1 : 0;", + "}", + "" + ], + "description": "Dart Testbench Template" + } +} \ No newline at end of file diff --git a/rohd_extension/src/conditional_completions.ts b/rohd_extension/src/conditional_completions.ts new file mode 100644 index 000000000..29c489df6 --- /dev/null +++ b/rohd_extension/src/conditional_completions.ts @@ -0,0 +1,1087 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * conditional_completions.ts + * Context-aware CompletionItemProvider for ROHD constructs. + * + * Four scopes: + * + * 1. FILE SCOPE — FSM (enum + class extends Module), Module scaffold + * only appear at file/top level, not inside a function like main(). + * + * 2. MODULE BODY — Pipeline, Sequential, Combinational appear when the + * cursor is inside a class that extends Module. + * + * 3. INSIDE _ALWAYS — If, If.block, Iff, Else, Case, CaseZ, + * CaseItem, and conditional assignment (<) only appear when the + * cursor is inside a Combinational or Sequential block. + * + * 4. TEST DIRECTORY — test(), group(), tearDown(), and ROHD simulation + * test scaffolds only appear for Dart files under a test/ directory. + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; + +// --------------------------------------------------------------------------- +// Context detection +// --------------------------------------------------------------------------- + +/** + * Determine the ROHD context at the cursor position. + * + * Returns a set of active scopes: 'file', 'module', 'always', 'test'. + */ +function detectContext( + document: vscode.TextDocument, + position: vscode.Position, +): Set { + const textBefore = document.getText( + new vscode.Range(new vscode.Position(0, 0), position), + ); + + const scopes = new Set(); + + // --- Pass 1: find brace positions for classes and functions --- + + const classPattern = /class\s+\w+(?:\s+extends\s+[^\{]+)?[^\{]*\{/g; + let m: RegExpExecArray | null; + const classBraces = new Set(); + while ((m = classPattern.exec(textBefore)) !== null) { + classBraces.add(m.index + m[0].length - 1); + } + + const funcPattern = /(?:void|Future|int|bool|String|dynamic|var|final|async|static)\s+\w+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?:async\s*)?\{/g; + const funcBraces = new Set(); + while ((m = funcPattern.exec(textBefore)) !== null) { + funcBraces.add(m.index + m[0].length - 1); + } + + // Also catch constructors: `ClassName(...) : super(...) {` or `ClassName(...) {` + const ctorPattern = /\b\w+\s*\([^)]*\)\s*(?::\s*super\([^)]*\)\s*)?\{/g; + while ((m = ctorPattern.exec(textBefore)) !== null) { + const bracePos = m.index + m[0].length - 1; + if (!classBraces.has(bracePos)) { + funcBraces.add(bracePos); + } + } + + // --- Pass 2: walk through text tracking brace depth + always detection --- + + let inString = false; + let stringChar = ''; + + interface BraceFrame { type: 'module' | 'function' | 'other' } + const braceStack: BraceFrame[] = []; + + let parenDepth = 0; + let alwaysParenDepth = -1; + + for (let i = 0; i < textBefore.length; i++) { + const ch = textBefore[i]; + + // String literals. + if (inString) { + if (ch === '\\') { i++; continue; } + if (ch === stringChar) { inString = false; } + continue; + } + if (ch === "'" || ch === '"') { + inString = true; + stringChar = ch; + continue; + } + + // Line comments. + if (ch === '/' && i + 1 < textBefore.length && textBefore[i + 1] === '/') { + i += 2; + while (i < textBefore.length && textBefore[i] !== '\n') { i++; } + continue; + } + + // Block comments. + if (ch === '/' && i + 1 < textBefore.length && textBefore[i + 1] === '*') { + i += 2; + while (i + 1 < textBefore.length && + !(textBefore[i] === '*' && textBefore[i + 1] === '/')) { i++; } + i++; + continue; + } + + // Braces. + if (ch === '{') { + let type: 'module' | 'function' | 'other' = 'other'; + if (classBraces.has(i)) { + type = 'module'; + } else if (funcBraces.has(i)) { + type = 'function'; + } + braceStack.push({ type }); + } else if (ch === '}') { + if (braceStack.length > 0) { + braceStack.pop(); + } + } + + // Parentheses — track Combinational/Sequential. + if (ch === '(') { + const lookback = textBefore.substring(Math.max(0, i - 30), i); + if (/(?:Combinational|Sequential)\s*$/.test(lookback)) { + alwaysParenDepth = parenDepth; + } + parenDepth++; + } else if (ch === ')') { + parenDepth--; + if (alwaysParenDepth >= 0 && parenDepth <= alwaysParenDepth) { + alwaysParenDepth = -1; + } + } + } + + // --- Determine scopes --- + + const insideModule = braceStack.some(f => f.type === 'module'); + const insideFunction = braceStack.some(f => f.type === 'function'); + const insideAlways = alwaysParenDepth >= 0 && insideFunction; + + if (insideAlways) { scopes.add('always'); } + if (insideModule) { scopes.add('module'); } + if (!insideFunction && !insideModule) { scopes.add('file'); } + if (/(^|[\\/])test[\\/]/.test(document.uri.fsPath)) { scopes.add('test'); } + + return scopes; +} + +interface EnclosingClassInfo { + className: string; + enumInsertionPosition: vscode.Position; +} + +function findEnclosingClassInfo( + document: vscode.TextDocument, + position: vscode.Position, +): EnclosingClassInfo | undefined { + const textBefore = document.getText( + new vscode.Range(new vscode.Position(0, 0), position), + ); + + const classPattern = /class\s+(\w+)(?:\s+extends\s+[^\{]+)?[^\{]*\{/g; + let match: RegExpExecArray | null; + const classBraces = new Map(); + while ((match = classPattern.exec(textBefore)) !== null) { + classBraces.set(match.index + match[0].length - 1, { + className: match[1], + classStart: match.index, + }); + } + + const funcPattern = /(?:void|Future|int|bool|String|dynamic|var|final|async|static)\s+\w+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?:async\s*)?\{/g; + const funcBraces = new Set(); + while ((match = funcPattern.exec(textBefore)) !== null) { + funcBraces.add(match.index + match[0].length - 1); + } + + const ctorPattern = /\b\w+\s*\([^)]*\)\s*(?::\s*super\([^)]*\)\s*)?\{/g; + while ((match = ctorPattern.exec(textBefore)) !== null) { + const bracePos = match.index + match[0].length - 1; + if (!classBraces.has(bracePos)) { + funcBraces.add(bracePos); + } + } + + interface BraceFrame { + type: 'class' | 'function' | 'other'; + className?: string; + classStart?: number; + } + + const braceStack: BraceFrame[] = []; + let inString = false; + let stringChar = ''; + + for (let i = 0; i < textBefore.length; i++) { + const ch = textBefore[i]; + + if (inString) { + if (ch === '\\') { i++; continue; } + if (ch === stringChar) { inString = false; } + continue; + } + if (ch === "'" || ch === '"') { + inString = true; + stringChar = ch; + continue; + } + + if (ch === '/' && i + 1 < textBefore.length && textBefore[i + 1] === '/') { + i += 2; + while (i < textBefore.length && textBefore[i] !== '\n') { i++; } + continue; + } + + if (ch === '/' && i + 1 < textBefore.length && textBefore[i + 1] === '*') { + i += 2; + while (i + 1 < textBefore.length && + !(textBefore[i] === '*' && textBefore[i + 1] === '/')) { i++; } + i++; + continue; + } + + if (ch === '{') { + const classInfo = classBraces.get(i); + if (classInfo !== undefined) { + braceStack.push({ type: 'class', ...classInfo }); + } else if (funcBraces.has(i)) { + braceStack.push({ type: 'function' }); + } else { + braceStack.push({ type: 'other' }); + } + } else if (ch === '}') { + braceStack.pop(); + } + } + + const enclosingClass = [...braceStack].reverse() + .find(frame => frame.type === 'class' && + frame.className !== undefined && frame.classStart !== undefined); + if (enclosingClass?.className === undefined || enclosingClass.classStart === undefined) { + return undefined; + } + + let insertionLine = document.positionAt(enclosingClass.classStart).line; + while (insertionLine > 0 && document.lineAt(insertionLine - 1).text.trim().startsWith('@')) { + insertionLine--; + } + while (insertionLine > 0 && document.lineAt(insertionLine - 1).text.trim().startsWith('///')) { + insertionLine--; + } + + if (insertionLine > 0 && document.lineAt(insertionLine - 1).text.trim().endsWith('*/')) { + insertionLine--; + while (insertionLine > 0 && !document.lineAt(insertionLine).text.trim().startsWith('/**')) { + insertionLine--; + } + } + + return { + className: enclosingClass.className, + enumInsertionPosition: new vscode.Position(insertionLine, 0), + }; +} + +// --------------------------------------------------------------------------- +// Snippet definitions +// --------------------------------------------------------------------------- + +interface SnippetDef { + label: string; + prefixes: string[]; + body: string; + detail: string; + documentation: string; + sortOrder: string; +} + +// ---- ALWAYS scope ------------------------------------------------------- + +const ALWAYS_SNIPPETS: SnippetDef[] = [ + { + label: 'If (then/orElse)', + prefixes: ['If', 'if'], + body: [ + 'If(', + '\t${1:a}.gt(Const(0, width: ${1:a}.width)),', + '\tthen: [', + '\t\t${2:out} < ${1:a},', + '\t],', + '\torElse: [', + '\t\t${2:out} < ${3:b},', + '\t],', + '),', + ].join('\n'), + detail: 'If(cond, then: [...], orElse: [...])', + documentation: 'Inline conditional. Maps to if/else in SystemVerilog.', + sortOrder: '0a', + }, + { + label: 'If (then only)', + prefixes: ['If', 'if', 'ifthen'], + body: [ + 'If(', + '\t${1:a}.gt(Const(0, width: ${1:a}.width)),', + '\tthen: [', + '\t\t${2:out} < ${1:a},', + '\t],', + '),', + ].join('\n'), + detail: 'If(cond, then: [...])', + documentation: 'Simple conditional guard — no else branch.', + sortOrder: '0b', + }, + { + label: 'If nested (then/orElse chain)', + prefixes: ['If', 'if', 'ifnested', 'iforelse'], + body: [ + 'If(', + '\t${1:a}.gt(Const(0, width: ${1:a}.width)),', + '\tthen: [', + '\t\t${4:out} < ${1:a},', + '\t],', + '\torElse: [', + '\t\tIf(', + '\t\t\t${2:b}.gt(Const(0, width: ${2:b}.width)),', + '\t\t\tthen: [', + '\t\t\t\t${4:out} < ${2:b},', + '\t\t\t],', + '\t\t\torElse: [', + '\t\t\t\t${4:out} < Const(0, width: ${1:a}.width),', + '\t\t\t],', + '\t\t),', + '\t],', + '),', + ].join('\n'), + detail: 'If(a, ..., orElse: [If(b, ...)])', + documentation: 'Nested if / else-if / else chain using orElse nesting.', + sortOrder: '0c', + }, + { + label: 'If.block (Iff/ElseIf/Else)', + prefixes: ['If.block', 'ifblock', 'IfBlock'], + body: [ + 'If.block([', + '\tIff(${1:a}.gt(Const(0, width: ${1:a}.width)), [', + '\t\t${4:out} < ${1:a},', + '\t]),', + '\tElseIf(${2:b}.gt(Const(0, width: ${2:b}.width)), [', + '\t\t${4:out} < ${2:b},', + '\t]),', + '\tElse([', + '\t\t${4:out} < Const(0, width: ${1:a}.width),', + '\t]),', + ']),', + ].join('\n'), + detail: 'If.block([Iff(...), ElseIf(...), Else(...)])', + documentation: 'Flat if/else-if/else chain. First entry must be Iff (two f\'s).', + sortOrder: '0d', + }, + { + label: 'If.block (Iff/Else only)', + prefixes: ['If.block', 'ifblock', 'IfBlock'], + body: [ + 'If.block([', + '\tIff(${1:a}.gt(Const(0, width: ${1:a}.width)), [', + '\t\t${2:out} < ${1:a},', + '\t]),', + '\tElse([', + '\t\t${2:out} < Const(0, width: ${1:a}.width),', + '\t]),', + ']),', + ].join('\n'), + detail: 'If.block([Iff(...), Else(...)])', + documentation: 'Simple if/else using block style.', + sortOrder: '0e', + }, + { + label: 'If.block (from Iff)', + prefixes: ['Iff', 'iff'], + body: [ + 'If.block([', + '\tIff(${1:a}.gt(Const(0, width: ${1:a}.width)), [', + '\t\t${4:out} < ${1:a},', + '\t]),', + '\tElseIf(${2:b}.gt(Const(0, width: ${2:b}.width)), [', + '\t\t${4:out} < ${2:b},', + '\t]),', + '\tElse([', + '\t\t${4:out} < Const(0, width: ${1:a}.width),', + '\t]),', + ']),', + ].join('\n'), + detail: 'If.block([Iff(...), ElseIf(...), Else(...)])', + documentation: 'Complete if/elseif/else block. Bare Iff is only valid inside If.block, so this prefix expands to the full valid construct.', + sortOrder: '1a', + }, + { + label: 'Else', + prefixes: ['Else', 'else'], + body: 'Else([\n\t${1:out} < Const(0, width: ${2:a}.width),\n]),', + detail: 'Else([...])', + documentation: 'Final clause in an If.block chain.', + sortOrder: '1c', + }, + { + label: 'Case', + prefixes: ['Case', 'case'], + body: 'Case(${1:a}.gt(Const(0, width: ${1:a}.width)), [\n\tCaseItem(Const(0), [\n\t\t${5:out} < Const(0, width: ${1:a}.width),\n\t]),\n\tCaseItem(Const(1), [\n\t\t${5:out} < ${1:a},\n\t]),\n], defaultItem: [\n\t${5:out} < ${2:b},\n], conditionalType: ConditionalType.${4|none,unique,priority|}\n),', + detail: 'Case(expr, [CaseItem(...)], ...)', + documentation: 'Case statement — maps to case/unique case/priority case in SystemVerilog.', + sortOrder: '2a', + }, + { + label: 'CaseZ', + prefixes: ['CaseZ', 'caseZ', 'casez'], + body: 'CaseZ(${1:a}, [\n\tCaseItem(Const(LogicValue.ofString(\'${2:z1}\')), [\n\t\t${4:out} < ${1:a},\n\t]),\n\tCaseItem(Const(LogicValue.ofString(\'${3:10}\')), [\n\t\t${4:out} < ${5:b},\n\t]),\n], defaultItem: [\n\t${4:out} < Const(0, width: ${1:a}.width),\n], conditionalType: ConditionalType.${8|none,unique,priority|}\n),', + detail: 'CaseZ(expr, [CaseItem(...)])', + documentation: 'Like Case but with \'z\' don\'t-care matching.', + sortOrder: '2b', + }, + { + label: 'CaseItem', + prefixes: ['CaseItem', 'caseitem'], + body: 'CaseItem(${1:value}, [\n\t${2:out} < ${3:result},\n]),', + detail: 'CaseItem(value, [...])', + documentation: 'A single arm inside a Case or CaseZ.', + sortOrder: '2c', + }, + { + label: 'Conditional assign (<)', + prefixes: ['assign'], + body: '${1:out} < ${2:value},', + detail: 'out < value', + documentation: 'Conditional assignment using < operator.', + sortOrder: '3a', + }, +]; + +// ---- MODULE scope ------------------------------------------------------- + +const MODULE_SNIPPETS: SnippetDef[] = [ + { + label: 'Finite State Machine fallback (inside Module)', + prefixes: ['FSMCurrent', 'fsmCurrent'], + body: [ + '// Put this enum at file scope, outside this Module class:', + '// /// FSM states for this module.', + '// enum ${1:MyState} {', + '// /// Waiting for work.', + '// ${2:idle},', + '//', + '// /// Processing an active transaction.', + '// ${3:active},', + '//', + '// /// Completing the transaction.', + '// ${4:done},', + '// }', + 'final ${6:states} = [', + '\t// IDLE', + '\tState(', + '\t\t${1:MyState}.${2:idle},', + '\t\tevents: {', + '\t\t\t${7:a}.gt(Const(0, width: ${7:a}.width)): ${1:MyState}.${3:active},', + '\t\t},', + '\t\tactions: [', + '\t\t\t${8:out} < Const(0, width: ${7:a}.width),', + '\t\t],', + '\t),', + '\t// ACTIVE', + '\tState(', + '\t\t${1:MyState}.${3:active},', + '\t\tevents: {', + '\t\t\t${9:b}.gt(Const(0, width: ${9:b}.width)): ${1:MyState}.${4:done},', + '\t\t},', + '\t\tactions: [', + '\t\t\t${8:out} < ${7:a},', + '\t\t],', + '\t),', + '\t// DONE', + '\tState(', + '\t\t${1:MyState}.${4:done},', + '\t\tevents: {', + '\t\tConst(1): ${1:MyState}.${2:idle},', + '\t\t},', + '\t\tactions: [', + '\t\t\t${8:out} < ${9:b},', + '\t\t],', + '\t),', + '];', + 'FiniteStateMachine(${10:clk}, ${11:reset}, ${1:MyState}.${2:idle}, ${6:states});', + ].join('\n'), + detail: 'states + FiniteStateMachine for an existing Module', + documentation: 'Fallback FSM logic for use inside an existing Module. Prefer the context-aware fsm completion, which inserts the enum before the enclosing class.', + sortOrder: '0a', + }, + { + label: 'Pipeline', + prefixes: ['Pipeline', 'pipeline', 'PIpeline', 'Pipe', 'pipe'], + body: [ + 'final ${1:pipeline} = Pipeline(', + '\t${2:clk},', + '\tstages: [', + '\t\t(p) => [p.get(${3:a}) < p.get(${3:a}) + Const(1, width: ${3:a}.width)],', + '\t\t(p) => [p.get(${3:a}) < p.get(${3:a}) + Const(1, width: ${3:a}.width)],', + '\t],', + '\treset: ${4:reset},', + ');', + '${5:out} <= ${1:pipeline}.get(${3:a});', + ].join('\n'), + detail: 'Pipeline(clk, stages: [(p) => [...], ...])', + documentation: 'Pipelined logic — each stage is a `List Function(PipelineStageInfo p)`. Use `p.get(signal)` to access pipelined values. Stages use the same conditional syntax as Combinational.', + sortOrder: '0b', + }, + { + label: 'ReadyValidPipeline', + prefixes: ['ReadyValidPipeline', 'readyvalidpipeline', 'rvpipe', 'rvpipeline'], + body: [ + 'final ${3:validIn} = Logic(name: \'${3:validIn}\');', + 'final ${4:readyOut} = Logic(name: \'${4:readyOut}\');', + 'final ${8:validOut} = Logic(name: \'${8:validOut}\');', + 'final ${9:readyIn} = Logic(name: \'${9:readyIn}\');', + '', + 'final ${1:rvPipeline} = ReadyValidPipeline(', + '\t${2:clk},', + '\t${3:validIn},', + '\t${4:readyOut},', + '\tstages: [', + '\t\t(p) => [p.get(${5:a}) < p.get(${5:a}) + Const(1, width: ${5:a}.width)],', + '\t\t(p) => [p.get(${5:a}) < p.get(${5:a}) + Const(1, width: ${5:a}.width)],', + '\t],', + '\treset: ${6:reset},', + ');', + '${7:out} <= ${1:rvPipeline}.get(${5:a});', + '${8:validOut} <= ${1:rvPipeline}.validPipeOut;', + '${9:readyIn} <= ${1:rvPipeline}.readyPipeIn;', + ].join('\n'), + detail: 'ReadyValidPipeline(clk, stages: [...], valid, ready)', + documentation: 'Pipeline with ready/valid flow control. Same stage syntax as Pipeline, plus validPipeIn and readyPipeOut for backpressure.', + sortOrder: '0c', + }, + { + label: 'Sequential', + prefixes: ['Sequential', 'sequential', 'Seq', 'seq'], + body: 'Sequential(${1:clk}, [\n\tIf(${2:a}.gt(Const(0, width: ${2:a}.width)), then: [\n\t\t${4:out} < ${2:a},\n\t], orElse: [\n\t\t${4:out} < ${3:b},\n\t]),\n]);', + detail: 'Sequential(clk, [...])', + documentation: 'always_ff block triggered on clock edge.', + sortOrder: '0d', + }, + { + label: 'Combinational', + prefixes: ['Combinational', 'combinational', 'Comb', 'comb'], + body: 'Combinational([\n\t${1:out} < ${2:expression},\n]);', + detail: 'Combinational([...])', + documentation: 'always_comb block.', + sortOrder: '0e', + }, + { + label: 'Continuous assign (<=)', + prefixes: ['assign'], + body: '${1:out} <= ${2:a};', + detail: 'out <= a', + documentation: 'Continuous assignment outside Combinational/Sequential.', + sortOrder: '0f', + }, +]; + +// ---- FILE scope --------------------------------------------------------- + +const FILE_SNIPPETS: SnippetDef[] = [ + { + label: 'Finite State Machine (FSM)', + prefixes: ['FSM', 'fsm'], + body: [ + '/// FSM states for ${5:MyFSMModule}.', + 'enum ${1:MyState} {', + '\t/// Waiting for work.', + '\t${2:idle},', + '', + '\t/// Processing an active transaction.', + '\t${3:active},', + '', + '\t/// Completing the transaction.', + '\t${4:done},', + '}', + '', + 'class ${5:MyFSMModule} extends Module {', + '\t${5:MyFSMModule}(Logic clk, Logic reset, Logic ${6:input})', + '\t\t\t: super(name: \'${7:fsm_module}\') {', + '\t\tclk = addInput(\'clk\', clk);', + '\t\treset = addInput(\'reset\', reset);', + '\t\t${6:input} = addInput(\'${6:input}\', ${6:input});', + '', + '\t\tfinal ${8:output} = addOutput(\'${8:output}\');', + '', + '\t\tfinal states = [', + '\t\t\tState(${1:MyState}.${2:idle}, events: {', + '\t\t\t\t${6:input}: ${1:MyState}.${3:active},', + '\t\t\t}, actions: [', + '\t\t\t\t${8:output} < 0,', + '\t\t\t]),', + '\t\t\tState(${1:MyState}.${3:active}, events: {', + '\t\t\t\t${6:input}: ${1:MyState}.${4:done},', + '\t\t\t}, actions: [', + '\t\t\t\t${8:output} < 1,', + '\t\t\t]),', + '\t\t\tState(${1:MyState}.${4:done}, events: {', + '\t\t\t\tConst(1): ${1:MyState}.${2:idle},', + '\t\t\t}, actions: [', + '\t\t\t\t${8:output} < 0,', + '\t\t\t]),', + '\t\t];', + '', + '\t\tFiniteStateMachine(clk, reset, ${1:MyState}.${2:idle}, states);', + '\t}', + '}', + ].join('\n'), + detail: 'enum + class extends Module with FiniteStateMachine', + documentation: 'FSM scaffold — generates the enum and Module class at file scope.', + sortOrder: '0a', + }, + { + label: 'Module', + prefixes: ['Module', 'module', 'mod'], + body: [ + '/// A ROHD module with two data inputs and one output.', + 'class ${1:MyModule} extends Module {', + '\t/// The output of this module.', + '\tLogic get ${2:out} => output(\'${2:out}\');', + '', + '\t/// The clock input.', + '\tLogic get clk => input(\'clk\');', + '', + '\t/// The reset input.', + '\tLogic get reset => input(\'reset\');', + '', + '\t/// The configured data depth.', + '\tfinal int depth;', + '', + '\t/// Whether data should be latched.', + '\tfinal bool latchData;', + '', + '\t/// Constructs a ${1:MyModule}.', + '\t${1:MyModule}(', + '\t\tLogic ${3:clk},', + '\t\tLogic ${4:reset},', + '\t\tLogic ${5:a},', + '\t\tLogic ${6:b}, {', + '\t\tthis.depth = ${7:1},', + '\t\tthis.latchData = ${8:false},', + '\t\tString? definitionName,', + '\t\tsuper.name = \'${9:my_module}\',', + '\t\tsuper.reserveName,', + '\t\tsuper.reserveDefinitionName,', + '\t}) : super(definitionName: definitionName ?? \'${1:MyModule}\') {', + '\t\t// Register inputs and outputs of the module.', + '\t\t${3:clk} = addInput(\'clk\', ${3:clk});', + '\t\t${4:reset} = addInput(\'reset\', ${4:reset});', + '\t\t${5:a} = addInput(\'${5:a}\', ${5:a}, width: ${5:a}.width);', + '\t\t${6:b} = addInput(\'${6:b}\', ${6:b}, width: ${5:a}.width);', + '\t\tfinal ${2:out} = addOutput(\'${2:out}\', width: ${5:a}.width);', + '', + '\t\t${2:out} <= ${5:a};', + '\t}', + '}', + ].join('\n'), + detail: 'class MyModule extends Module { ... }', + documentation: 'ROHD Module scaffold with clk, reset, depth, latchData, addInput/addOutput, definitionName, and instance naming parameters.', + sortOrder: '0b', + }, + { + label: 'Interface (enum + setPorts + clone)', + prefixes: ['Interface', 'interface', 'intf'], + body: [ + '/// Port directions for ${4:MyInterface}.', + 'enum ${1:MyDirection} {', + '\t/// Ports entering the interface owner.', + '\t${2:inward},', + '', + '\t/// Ports leaving the interface owner.', + '\t${3:outward},', + '}', + '', + 'class ${4:MyInterface} extends Interface<${1:MyDirection}> {', + '\tLogic get ${5:dataIn} => port(\'${5:dataIn}\');', + '\tLogic get ${6:dataOut} => port(\'${6:dataOut}\');', + '\tLogic get ${7:clk} => port(\'${7:clk}\');', + '', + '\tfinal int ${8:width};', + '\t${4:MyInterface}({this.${8:width} = 8}) {', + '\t\tsetPorts([', + '\t\t\tLogic.port(\'${5:dataIn}\', ${8:width}),', + '\t\t\tLogic.port(\'${7:clk}\'),', + '\t\t], [', + '\t\t\t${1:MyDirection}.${2:inward},', + '\t\t]);', + '', + '\t\tsetPorts([', + '\t\t\tLogic.port(\'${6:dataOut}\', ${8:width}),', + '\t\t], [', + '\t\t\t${1:MyDirection}.${3:outward},', + '\t\t]);', + '\t}', + '', + '\t@override', + '\t${4:MyInterface} clone() => ${4:MyInterface}(${8:width}: ${8:width});', + '}', + ].join('\n'), + detail: 'enum + class extends Interface with clone()', + documentation: 'Classic ROHD Interface with direction enum, setPorts grouping, port getters, and clone(). Use with Module.addInterfacePorts(intf, inputTags: {...}, outputTags: {...}).', + sortOrder: '0c', + }, + { + label: 'PairInterface (provider/consumer)', + prefixes: ['PairInterface', 'pairinterface', 'pairintf'], + body: [ + 'class ${1:MyPairInterface} extends PairInterface {', + '\tLogic get ${2:clk} => port(\'${2:clk}\');', + '\tLogic get ${3:req} => port(\'${3:req}\');', + '\tLogic get ${4:rsp} => port(\'${4:rsp}\');', + '', + '\t${1:MyPairInterface}()', + '\t\t\t: super(', + '\t\t\t\t\tportsFromProvider: [Logic.port(\'${3:req}\')],', + '\t\t\t\t\tportsFromConsumer: [Logic.port(\'${4:rsp}\')],', + '\t\t\t\t\tsharedInputPorts: [Logic.port(\'${2:clk}\')],', + '\t\t\t\t);', + '', + '\t@override', + '\t${1:MyPairInterface} clone() => ${1:MyPairInterface}();', + '}', + ].join('\n'), + detail: 'class extends PairInterface { ... clone() }', + documentation: 'PairInterface with provider/consumer roles and shared inputs. Use with Module.addPairInterfacePorts(intf, PairRole.provider) or PairRole.consumer.', + sortOrder: '0d', + }, +]; + +// ---- TEST directory scope ----------------------------------------------- + +const TEST_SNIPPETS: SnippetDef[] = [ + { + label: 'test', + prefixes: ['test', 'Test'], + body: [ + 'test(\'${1:description}\', () async {', + '\texpect(${2:actual}, equals(${3:expected}));', + '});', + ].join('\n'), + detail: 'test(\'description\', () async { ... })', + documentation: 'Async package:test test case, common in ROHD and ROHD-HCL tests.', + sortOrder: '0a', + }, + { + label: 'group', + prefixes: ['group', 'Group'], + body: [ + 'group(\'${1:description}\', () {', + '\ttest(\'${2:case}\', () async {', + '\t\texpect(${3:actual}, equals(${4:expected}));', + '\t});', + '});', + ].join('\n'), + detail: 'group(\'description\', () { test(...) })', + documentation: 'Package:test group with an async test inside.', + sortOrder: '0b', + }, + { + label: 'ROHD tearDown reset', + prefixes: ['tearDown', 'teardown', 'resetTest'], + body: [ + 'tearDown(() async {', + '\tawait Simulator.reset();', + '});', + ].join('\n'), + detail: 'tearDown(() async { await Simulator.reset(); })', + documentation: 'Reset the ROHD simulator between tests.', + sortOrder: '0c', + }, + { + label: 'ROHD simulation test', + prefixes: ['rohdtest', 'simtest', 'testsim'], + body: [ + 'test(\'${1:module behavior}\', () async {', + '\tfinal ${2:clk} = SimpleClockGenerator(10).clk;', + '\tfinal ${3:reset} = Logic()..put(0);', + '', + '\tfinal ${4:dut} = ${5:MyModule}(${2:clk}, ${3:reset});', + '\tawait ${4:dut}.build();', + '', + '\tunawaited(Simulator.run());', + '', + '\t${3:reset}.put(1);', + '\tawait ${2:clk}.nextNegedge;', + '\tawait ${2:clk}.nextNegedge;', + '\t${3:reset}.put(0);', + '\tawait ${2:clk}.nextNegedge;', + '', + '\texpect(${4:dut}.${6:out}.value.toInt(), equals(${7:0}));', + '', + '\tawait Simulator.endSimulation();', + '});', + ].join('\n'), + detail: 'test(...) with SimpleClockGenerator, reset, build, Simulator.run()', + documentation: 'ROHD simulation test scaffold based on common ROHD-HCL tests. Requires dart:async, rohd, and package:test imports.', + sortOrder: '0d', + }, +]; + +// --------------------------------------------------------------------------- +// Build completion items +// --------------------------------------------------------------------------- + +function buildItems( + snippets: SnippetDef[], + range?: vscode.Range, + includeSnippet?: (snippet: SnippetDef) => boolean, +): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + for (const snippet of snippets) { + if (includeSnippet !== undefined && !includeSnippet(snippet)) { + continue; + } + + for (const prefix of snippet.prefixes) { + const item = new vscode.CompletionItem( + `ROHD: ${snippet.label}`, + vscode.CompletionItemKind.Snippet, + ); + item.detail = `${snippet.label} — ${snippet.detail}`; + item.documentation = new vscode.MarkdownString(snippet.documentation); + item.filterText = prefix; + item.insertText = new vscode.SnippetString(snippet.body); + item.range = range; + item.sortText = `!${snippet.sortOrder}${prefix}`; + item.preselect = true; + items.push(item); + } + } + return items; +} + +const alwaysItems = buildItems(ALWAYS_SNIPPETS); +const moduleItems = buildItems(MODULE_SNIPPETS); +const fileItems = buildItems(FILE_SNIPPETS); +const testItems = buildItems(TEST_SNIPPETS); + +const COMPLETION_TRIGGER_CHARACTERS = Array.from(new Set([ + ...ALWAYS_SNIPPETS, + ...MODULE_SNIPPETS, + ...FILE_SNIPPETS, + ...TEST_SNIPPETS, +].flatMap(snippet => snippet.prefixes.map(prefix => prefix[0])))).concat('.'); + +function buildPipelineTypedItems( + range: vscode.Range, + readyValid: boolean, + typedPrefix: string, +): vscode.CompletionItem[] { + const snippet = MODULE_SNIPPETS.find(candidate => + candidate.label === (readyValid ? 'ReadyValidPipeline' : 'Pipeline'), + ); + if (snippet === undefined) { + return []; + } + + const item = new vscode.CompletionItem( + readyValid ? 'ROHD ReadyValidPipeline' : 'ROHD Pipeline', + vscode.CompletionItemKind.Snippet, + ); + item.detail = `ROHD ${snippet.label} — ${snippet.detail}`; + item.documentation = new vscode.MarkdownString(snippet.documentation); + item.filterText = typedPrefix; + item.insertText = new vscode.SnippetString(snippet.body); + item.range = range; + item.sortText = '0000_rohd_pipeline'; + item.preselect = true; + return [item]; +} + +function buildFsmInModuleItems( + enclosingClass: EnclosingClassInfo, +): vscode.CompletionItem[] { + const enumName = `${enclosingClass.className}State`; + const enumText = [ + `/// FSM states for ${enclosingClass.className}.`, + `enum ${enumName} {`, + ' /// Waiting for work.', + ' idle,', + '', + ' /// Processing an active transaction.', + ' active,', + '', + ' /// Completing the transaction.', + ' done,', + '}', + '', + ].join('\n'); + const body = [ + 'final ${2:states} = [', + '\t// IDLE', + '\tState(', + `\t\t${enumName}.idle,`, + '\t\tevents: {', + `\t\t\t${'${3:a}'}.gt(Const(0, width: ${'${3:a}'}.width)): ${enumName}.active,`, + '\t\t},', + '\t\tactions: [', + `\t\t\t${'${4:out}'} < Const(0, width: ${'${3:a}'}.width),`, + '\t\t],', + '\t),', + '\t// ACTIVE', + '\tState(', + `\t\t${enumName}.active,`, + '\t\tevents: {', + `\t\t\t${'${5:b}'}.gt(Const(0, width: ${'${5:b}'}.width)): ${enumName}.done,`, + '\t\t},', + '\t\tactions: [', + `\t\t\t${'${4:out}'} < ${'${3:a}'},`, + '\t\t],', + '\t),', + '\t// DONE', + '\tState(', + `\t\t${enumName}.done,`, + '\t\tevents: {', + `\t\t\tConst(1): ${enumName}.idle,`, + '\t\t},', + '\t\tactions: [', + `\t\t\t${'${4:out}'} < ${'${5:b}'},`, + '\t\t],', + '\t),', + '];', + `FiniteStateMachine(${'${6:clk}'}, ${'${7:reset}'}, ${enumName}.idle, ${'${2:states}'});`, + ].join('\n'); + + return ['FSM', 'fsm'].map(prefix => { + const item = new vscode.CompletionItem( + 'ROHD: Finite State Machine (inside Module)', + vscode.CompletionItemKind.Snippet, + ); + item.detail = 'Finite State Machine (inside Module) — states + FiniteStateMachine for an existing Module'; + item.documentation = new vscode.MarkdownString( + 'FSM logic for use inside an existing Module. The enum is inserted before the enclosing class declaration.', + ); + item.filterText = prefix; + item.insertText = new vscode.SnippetString(body); + item.additionalTextEdits = [ + vscode.TextEdit.insert(enclosingClass.enumInsertionPosition, enumText), + ]; + item.sortText = `!0a${prefix}`; + item.preselect = true; + return item; + }); +} + +function ifBlockTypedPrefixRange( + document: vscode.TextDocument, + position: vscode.Position, +): vscode.Range | undefined { + const linePrefix = document.lineAt(position.line).text.substring(0, position.character); + const match = /(?:If|if)\.(?:block)?$/.exec(linePrefix); + if (match === null) { + return undefined; + } + + return new vscode.Range( + new vscode.Position(position.line, position.character - match[0].length), + position, + ); +} + +function iffTypedPrefixRange( + document: vscode.TextDocument, + position: vscode.Position, +): vscode.Range | undefined { + const linePrefix = document.lineAt(position.line).text.substring(0, position.character); + const match = /(?:Iff|iff)$/.exec(linePrefix); + if (match === null) { + return undefined; + } + + return new vscode.Range( + new vscode.Position(position.line, position.character - match[0].length), + position, + ); +} + +function pipelineTypedPrefix( + document: vscode.TextDocument, + position: vscode.Position, +): { range: vscode.Range; readyValid: boolean; typedPrefix: string } | undefined { + const linePrefix = document.lineAt(position.line).text.substring(0, position.character); + const match = /(?:ReadyValidPipeline|readyvalidpipeline|rvpipeline|rvpipe|PIpeline|Pipeline|pipeline|Pipe|pipe)$/.exec(linePrefix); + if (match === null) { + return undefined; + } + + return { + range: new vscode.Range( + new vscode.Position(position.line, position.character - match[0].length), + position, + ), + readyValid: /^(?:ReadyValidPipeline|readyvalidpipeline|rvpipeline|rvpipe)$/.test(match[0]), + typedPrefix: match[0], + }; +} + +// --------------------------------------------------------------------------- +// Completion provider +// --------------------------------------------------------------------------- + +class RohdContextCompletionProvider + implements vscode.CompletionItemProvider +{ + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext, + ): vscode.CompletionItem[] | undefined { + const scopes = detectContext(document, position); + const items: vscode.CompletionItem[] = []; + + const ifBlockRange = ifBlockTypedPrefixRange(document, position); + if (ifBlockRange !== undefined && scopes.has('always')) { + return buildItems( + ALWAYS_SNIPPETS, + ifBlockRange, + snippet => snippet.prefixes.some(prefix => + prefix === 'If.block' || prefix === 'ifblock' || prefix === 'IfBlock', + ), + ); + } + + const iffRange = iffTypedPrefixRange(document, position); + if (iffRange !== undefined && scopes.has('always')) { + return buildItems( + ALWAYS_SNIPPETS, + iffRange, + snippet => snippet.label === 'If.block (from Iff)', + ); + } + + const pipelinePrefix = pipelineTypedPrefix(document, position); + if (pipelinePrefix !== undefined && scopes.has('module')) { + return buildPipelineTypedItems( + pipelinePrefix.range, + pipelinePrefix.readyValid, + pipelinePrefix.typedPrefix, + ); + } + + if (scopes.has('always')) { items.push(...alwaysItems); } + if (scopes.has('module')) { + const enclosingClass = findEnclosingClassInfo(document, position); + if (enclosingClass !== undefined) { + items.push(...buildFsmInModuleItems(enclosingClass)); + } + items.push(...moduleItems.filter(item => item.label !== 'ROHD: Finite State Machine (inside Module)')); + } + if (scopes.has('file')) { items.push(...fileItems); } + if (scopes.has('test')) { items.push(...testItems); } + + return items.length > 0 ? items : undefined; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function activate(_context: vscode.ExtensionContext): vscode.Disposable { + const provider = new RohdContextCompletionProvider(); + + return vscode.languages.registerCompletionItemProvider( + { language: 'dart', scheme: 'file' }, + provider, + ...COMPLETION_TRIGGER_CHARACTERS, + ); +} diff --git a/rohd_extension/src/debug_tracker.ts b/rohd_extension/src/debug_tracker.ts new file mode 100644 index 000000000..ae556d9e8 --- /dev/null +++ b/rohd_extension/src/debug_tracker.ts @@ -0,0 +1,384 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * debug_tracker.ts + * Debug Adapter Tracker for the ROHD VS Code extension. + * + * Registers with VS Code's debug infrastructure via + * `registerDebugAdapterTrackerFactory` to automatically intercept Dart + * debug sessions. Extracts the VM Service URI from DAP messages and + * obtains the DTD URI from the Dart extension API — no manual + * configuration required. + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; +import * as dtdBridge from './dtd_bridge'; + +const output = vscode.window.createOutputChannel('ROHD Debug Tracker'); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** Per-session tracking data. */ +interface SessionInfo { + vmServiceUri?: string; + vmServiceForwardedUri?: string; + dtdUri?: string; + dtdForwardedUri?: string; +} + +const sessions = new Map(); + +/** The DTD URI obtained from the Dart extension API. */ +let dartExtDtdUri: string | undefined; +let dartExtDtdForwardedUri: string | undefined; + +// --------------------------------------------------------------------------- +// URI helpers (shared logic with uri_forwarder.ts) +// --------------------------------------------------------------------------- + +async function resolveForwardedUri( + rawWsUri: string, +): Promise { + try { + const httpUri = rawWsUri + .replace(/^ws:\/\//, 'http://') + .replace(/^wss:\/\//, 'https://'); + + const parsed = vscode.Uri.parse(httpUri); + const resolved = await vscode.env.asExternalUri(parsed); + + const scheme = resolved.scheme === 'https' ? 'wss' : 'ws'; + const forwarded = `${scheme}://${resolved.authority}${parsed.path}`; + + if (resolved.authority !== parsed.authority) { + return forwarded; + } + return undefined; + } catch { + return undefined; + } +} + +function normalizeWsUri(uri: string, ensureWsSuffix: boolean): string { + let u = uri; + if (u.startsWith('http://')) { + u = u.replace('http://', 'ws://'); + } else if (u.startsWith('https://')) { + u = u.replace('https://', 'wss://'); + } + if (ensureWsSuffix && !u.endsWith('/ws')) { + u = u.replace(/\/?$/, '/ws'); + } + return u; +} + +// --------------------------------------------------------------------------- +// Dart extension DTD API +// --------------------------------------------------------------------------- + +interface DartExtensionApi { + dtdUri?: string; + onDtdUriChanged?: ( + listener: (uri: string | undefined) => void, + thisArgs?: unknown, + disposables?: vscode.Disposable[], + ) => vscode.Disposable; +} + +function watchDtdFromDartExtension(context: vscode.ExtensionContext): void { + const dartExt = vscode.extensions.getExtension('dart-code.dart-code'); + if (!dartExt) { + output.appendLine('[DTD] Dart extension not found — will rely on DAP events only.'); + return; + } + + async function processDtd(rawUri: string): Promise { + const wsUri = normalizeWsUri(rawUri, false); + dartExtDtdUri = wsUri; + output.appendLine(`[DTD] From Dart extension API: ${wsUri}`); + + const forwarded = await resolveForwardedUri(wsUri); + dartExtDtdForwardedUri = forwarded; + if (forwarded) { + output.appendLine(`[DTD] Forwarded: ${forwarded}`); + } + + // Feed the original (non-forwarded) DTD URI to the bridge. + // The bridge runs on the same host as the DTD daemon, so it uses + // the local port. The forwarded port is only for remote clients. + dtdBridge.connectIfNeeded(wsUri).catch((err) => { + output.appendLine(`[DTD] Bridge connect failed: ${err}`); + }); + } + + function handleApi(api: DartExtensionApi): void { + if (api.dtdUri) { + processDtd(api.dtdUri).catch((err) => { + output.appendLine(`[DTD] Error processing DTD URI: ${err}`); + }); + } + if (api.onDtdUriChanged) { + const disposable = api.onDtdUriChanged((uri) => { + if (uri) { + processDtd(uri).catch((err) => { + output.appendLine(`[DTD] Error on DTD URI change: ${err}`); + }); + } + }); + if (disposable) { + context.subscriptions.push(disposable); + } + } + } + + if (dartExt.isActive) { + handleApi(dartExt.exports as DartExtensionApi); + } else { + dartExt.activate().then( + (api) => handleApi(api as DartExtensionApi), + (err) => output.appendLine(`[DTD] Dart extension activation failed: ${err}`), + ); + } +} + +// --------------------------------------------------------------------------- +// Banner output +// --------------------------------------------------------------------------- + +const EXTENSION_VERSION = '0.1.0'; + +async function printSessionBanner( + session: vscode.DebugSession, + info: SessionInfo, +): Promise { + const lines: string[] = []; + + lines.push(`ROHD ${EXTENSION_VERSION}: Extension loaded for FLC crossprobing.`); + lines.push(''); + + // DTD: prefer session-specific, fall back to Dart extension API + const dtdOrig = info.dtdUri ?? dartExtDtdUri; + const dtdFwd = info.dtdForwardedUri ?? dartExtDtdForwardedUri; + if (dtdOrig) { + lines.push('DTD:'); + lines.push(` URI: ${dtdOrig}`); + if (dtdFwd) { + lines.push(` Fwd: ${dtdFwd}`); + } + } + + if (info.vmServiceUri) { + lines.push('VM:'); + lines.push(` URI: ${info.vmServiceUri}`); + if (info.vmServiceForwardedUri) { + lines.push(` Fwd: ${info.vmServiceForwardedUri}`); + } + } + + const banner = '═'.repeat(60); + const block = [banner, ...lines, banner].join('\n'); + + // Debug console + vscode.debug.activeDebugConsole.appendLine(''); + vscode.debug.activeDebugConsole.appendLine(block); + + // Output channel (persists across sessions) + output.appendLine(''); + output.appendLine(block); + + // Popup with DTD URI for quick copy + const popupUri = dtdFwd ?? dtdOrig; + if (popupUri) { + const action = await vscode.window.showInformationMessage( + `ROHD DTD: ${popupUri}`, + 'Copy', + ); + if (action === 'Copy') { + await vscode.env.clipboard.writeText(popupUri); + vscode.window.showInformationMessage('DTD URI copied to clipboard.'); + } + } +} + +// --------------------------------------------------------------------------- +// Debug Adapter Tracker +// --------------------------------------------------------------------------- + +class RohdDebugAdapterTracker implements vscode.DebugAdapterTracker { + private readonly session: vscode.DebugSession; + + constructor(session: vscode.DebugSession) { + this.session = session; + } + + onWillStartSession(): void { + output.appendLine( + `[Tracker] Debug session starting: "${this.session.name}" (${this.session.id})`, + ); + sessions.set(this.session.id, {}); + } + + onDidSendMessage(message: unknown): void { + // The Dart debug adapter sends an "event" message with + // event === "dart.debuggerUris" containing the VM Service URI. + const msg = message as Record; + if (msg.type !== 'event') { return; } + + if (msg.event === 'dart.debuggerUris') { + const body = msg.body as Record | undefined; + const vmUri = body?.vmServiceUri as string | undefined; + if (vmUri) { + this.processVmServiceUri(vmUri); + } + } + } + + onWillStopSession(): void { + output.appendLine( + `[Tracker] Debug session ending: "${this.session.name}" (${this.session.id})`, + ); + sessions.delete(this.session.id); + } + + onError(error: Error): void { + output.appendLine(`[Tracker] Error in session "${this.session.name}": ${error.message}`); + } + + private async processVmServiceUri(rawUri: string): Promise { + const vmUri = normalizeWsUri(rawUri, true); + const info = sessions.get(this.session.id) ?? {}; + info.vmServiceUri = vmUri; + + output.appendLine(`[Tracker] VM Service URI: ${vmUri}`); + + const forwarded = await resolveForwardedUri(vmUri); + info.vmServiceForwardedUri = forwarded; + if (forwarded) { + output.appendLine(`[Tracker] VM Service Forwarded: ${forwarded}`); + } + + // Copy DTD info from Dart extension API into session info + if (dartExtDtdUri) { + info.dtdUri = dartExtDtdUri; + info.dtdForwardedUri = dartExtDtdForwardedUri; + + // Ensure the DTD bridge is connected now that we have a session. + // Use the original (non-forwarded) URI — bridge is local. + dtdBridge.connectIfNeeded(dartExtDtdUri).catch((err) => { + output.appendLine(`[Tracker] Bridge connect failed: ${err}`); + }); + } + + sessions.set(this.session.id, info); + await printSessionBanner(this.session, info); + } +} + +class RohdDebugAdapterTrackerFactory implements vscode.DebugAdapterTrackerFactory { + createDebugAdapterTracker( + session: vscode.DebugSession, + ): vscode.ProviderResult { + return new RohdDebugAdapterTracker(session); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Get session info for the active debug session. */ +export function getActiveSessionInfo(): SessionInfo | undefined { + const session = vscode.debug.activeDebugSession; + if (!session) { return undefined; } + return sessions.get(session.id); +} + +/** Get the DTD URI (forwarded preferred, original fallback). */ +export function getDtdUri(): string | undefined { + const info = getActiveSessionInfo(); + return info?.dtdForwardedUri ?? info?.dtdUri ?? dartExtDtdForwardedUri ?? dartExtDtdUri; +} + +/** Get the VM Service URI (forwarded preferred, original fallback). */ +export function getVmServiceUri(): string | undefined { + const info = getActiveSessionInfo(); + return info?.vmServiceForwardedUri ?? info?.vmServiceUri; +} + +export function activate(context: vscode.ExtensionContext): void { + output.appendLine('[Debug Tracker] Activating...'); + + // Watch for DTD URI from the Dart extension API + watchDtdFromDartExtension(context); + + // Register the tracker factory for Dart debug sessions + context.subscriptions.push( + vscode.debug.registerDebugAdapterTrackerFactory( + 'dart', + new RohdDebugAdapterTrackerFactory(), + ), + ); + + // Also register for generic debug types in case Dart sessions + // use a different type identifier + context.subscriptions.push( + vscode.debug.registerDebugAdapterTrackerFactory( + '*', + { + createDebugAdapterTracker(session: vscode.DebugSession) { + // Only track Dart-related sessions + if (session.type === 'dart' || session.type === 'flutter') { + // Already tracked by the 'dart' factory above; skip duplication. + return undefined; + } + return undefined; + }, + }, + ), + ); + + // Command to show forwarded URIs for the active session + context.subscriptions.push( + vscode.commands.registerCommand('rohd.showForwardedUris', () => { + const info = getActiveSessionInfo(); + if (!info) { + vscode.window.showWarningMessage('No active Dart debug session.'); + return; + } + + const lines: string[] = []; + if (info.dtdUri) { + lines.push(`DTD: ${info.dtdUri}`); + if (info.dtdForwardedUri) { lines.push(`DTD Fwd: ${info.dtdForwardedUri}`); } + } else if (dartExtDtdUri) { + lines.push(`DTD: ${dartExtDtdUri}`); + if (dartExtDtdForwardedUri) { lines.push(`DTD Fwd: ${dartExtDtdForwardedUri}`); } + } + if (info.vmServiceUri) { + lines.push(`VM: ${info.vmServiceUri}`); + if (info.vmServiceForwardedUri) { lines.push(`VM Fwd: ${info.vmServiceForwardedUri}`); } + } + + if (lines.length === 0) { + vscode.window.showWarningMessage('URIs not yet available. Start a debug session first.'); + return; + } + + output.appendLine(lines.join('\n')); + output.show(true); + }), + ); + + output.appendLine('[Debug Tracker] Activated — tracking Dart debug sessions.'); +} + +export function deactivate(): void { + sessions.clear(); + dartExtDtdUri = undefined; + dartExtDtdForwardedUri = undefined; +} diff --git a/rohd_extension/src/dtd_bridge.ts b/rohd_extension/src/dtd_bridge.ts new file mode 100644 index 000000000..52f70bbef --- /dev/null +++ b/rohd_extension/src/dtd_bridge.ts @@ -0,0 +1,439 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * dtd_bridge.ts + * DTD (Dart Tooling Daemon) bridge for receiving cross-probe source + * navigation requests from the ROHD DevTools extension. + * + * Registers a `rohd.goToSource` service method on the DTD so that the + * DevTools extension can send resolved SourceFrame lists for navigation. + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; +import { openSourceLocations, resolveFrames } from './source_navigator'; +import * as flcService from './flc_service'; + +const output = vscode.window.createOutputChannel('ROHD DTD Bridge'); + +// --------------------------------------------------------------------------- +// Minimal JSON-RPC 2.0 server over WebSocket +// --------------------------------------------------------------------------- + +let ws: import('ws').WebSocket | undefined; +let nextId = 1; +const pendingRequests = new Map void; + reject: (error: Error) => void; +}>(); + +type RpcHandler = (params: Record) => Promise; +const methods = new Map(); + +const rohdDtdBridgeCapabilities = { + rohdDtdBridge: 1, + owner: 'rohd-vscode-extension', +}; + +function dtdResult(result: Record): Record { + return { type: 'Success', ...result }; +} + +function sendJsonRpc(data: Record): void { + if (ws?.readyState === 1 /* OPEN */) { + ws.send(JSON.stringify(data)); + } +} + +function handleMessage(raw: string): void { + let msg: Record; + try { + msg = JSON.parse(raw); + } catch { + return; + } + + // Response to a request we sent. + if ('id' in msg && ('result' in msg || 'error' in msg)) { + const id = msg.id as number; + const pending = pendingRequests.get(id); + if (pending) { + pendingRequests.delete(id); + if ('error' in msg) { + pending.reject(new Error(JSON.stringify(msg.error))); + } else { + pending.resolve(msg.result); + } + } + return; + } + + // Incoming request or notification. + if ('method' in msg) { + const method = msg.method as string; + const params = (msg.params ?? {}) as Record; + const handler = methods.get(method); + + if (handler && 'id' in msg) { + // Request — send response. + handler(params) + .then(result => sendJsonRpc({ jsonrpc: '2.0', id: msg.id, result })) + .catch(err => + sendJsonRpc({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32000, message: String(err) }, + }), + ); + } else if (handler) { + // Notification — fire and forget. + handler(params).catch(() => {}); + } + } +} + +/** Send a JSON-RPC request and wait for the response. */ +async function rpcRequest( + method: string, + params?: Record, +): Promise { + return new Promise((resolve, reject) => { + const id = nextId++; + pendingRequests.set(id, { resolve, reject }); + sendJsonRpc({ jsonrpc: '2.0', id, method, params: params ?? {} }); + + // Timeout after 10 seconds. + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + reject(new Error(`RPC timeout: ${method}`)); + } + }, 10000); + }); +} + +// --------------------------------------------------------------------------- +// Service registration +// --------------------------------------------------------------------------- + +/** + * Register the `rohd.goToSource` handler. + * + * When the DevTools extension calls this service via DTD, the handler + * parses the SourceFrame list and delegates to the existing + * `openSourceLocations` command. + */ +function registerGoToSourceHandler(): void { + methods.set('rohd.goToSource', async (params) => { + const framesRaw = params.frames; + if (!Array.isArray(framesRaw) || framesRaw.length === 0) { + return dtdResult({ status: 'error', message: 'No frames provided' }); + } + + const index = typeof params.index === 'number' ? params.index : 0; + + output.appendLine( + `[DTD] goToSource: ${framesRaw.length} frame(s), index=${index}`, + ); + + // Delegate to the existing source navigator. + await openSourceLocations({ frames: framesRaw, index }); + return dtdResult({ status: 'ok', navigated: framesRaw.length }); + }); + + methods.set('rohd.resolveFrames', async (params) => { + const framesRaw = params.frames; + if (!Array.isArray(framesRaw) || framesRaw.length === 0) { + return dtdResult({ status: 'error', message: 'No frames provided' }); + } + + output.appendLine( + `[DTD] resolveFrames: ${framesRaw.length} frame(s)`, + ); + + const enriched = await resolveFrames(framesRaw); + return dtdResult({ status: 'ok', frames: enriched }); + }); + + // Query which source formats are available for a module. + // params: { flcPath: string, module: string | null } + methods.set('rohd.queryModule', async (params) => { + const flcPath = params.flcPath as string | undefined; + const moduleName = (params.module as string | null) ?? null; + + if (!flcPath) { + return dtdResult({ status: 'error', message: 'flcPath is required' }); + } + + output.appendLine(`[DTD] queryModule: module=${moduleName ?? '(any)'}, flcPath=${flcPath}`); + const info = flcService.queryModule(flcPath, moduleName); + return dtdResult({ status: 'ok', ...info }); + }); + + // Look up signal source frames from a persisted .flc.json file. + // params: { flcPath: string, module: string | null, signal: string, format?: string } + methods.set('rohd.lookupSignal', async (params) => { + const flcPath = params.flcPath as string | undefined; + const moduleName = (params.module as string | null) ?? null; + const signalName = params.signal as string | undefined; + const format = params.format as string | undefined; + + if (!flcPath || !signalName) { + return dtdResult({ status: 'error', message: 'flcPath and signal are required' }); + } + + output.appendLine( + `[DTD] lookupSignal: signal=${signalName}, module=${moduleName ?? '(any)'}, format=${format ?? 'all'}`, + ); + const frames = flcService.lookupSignal(flcPath, moduleName, signalName, format); + return dtdResult({ status: 'ok', frames }); + }); +} + +// --------------------------------------------------------------------------- +// DTD connection lifecycle +// --------------------------------------------------------------------------- + +let reconnectTimer: ReturnType | undefined; +let reconnectAttempts = 0; +let dtdUri: string | undefined; + +// In-flight connection guard. `connectToDtd` is async (it awaits the dynamic +// `ws` import before assigning `ws`), so two near-simultaneous callers — e.g. +// activation-time discovery and the debug tracker — can both pass the +// `isConnected()` check (which is only OPEN, never CONNECTING) and open a +// second socket to the same URI. The second socket then fails registration +// with "Service already registered by another client." This flag is set +// synchronously at the top of `connectToDtd` to close that window. +let connecting = false; +let connectingUri: string | undefined; + +/** + * Connect to the DTD and register services. + * + * @param uri WebSocket URI of the Dart Tooling Daemon. + */ +async function connectToDtd(uri: string): Promise { + // Synchronous dedupe: ignore a second connect to a URI we are already + // connecting to or connected to. + if (connecting && connectingUri === uri) { + output.appendLine(`[DTD] Connect already in progress for ${uri}; skipping`); + return false; + } + if (isConnected() && dtdUri === uri) { + return true; + } + + connecting = true; + connectingUri = uri; + dtdUri = uri; + + try { + // Dynamic import — ws is a Node.js dependency. + const WebSocket = (await import('ws')).default; + + ws = new WebSocket(uri); + + return new Promise((resolve) => { + ws!.on('open', () => { + output.appendLine(`[DTD] Connected to ${uri}`); + reconnectAttempts = 0; + connecting = false; + connectingUri = undefined; + + registerGoToSourceHandler(); + + // Register ourselves as a service on the DTD. + rpcRequest('registerService', { + service: 'rohd', + method: 'goToSource', + capabilities: rohdDtdBridgeCapabilities, + }).then(() => { + output.appendLine('[DTD] Registered rohd.goToSource service'); + }).catch((err) => { + output.appendLine(`[DTD] Service registration note: ${err}`); + }); + + rpcRequest('registerService', { + service: 'rohd', + method: 'resolveFrames', + capabilities: rohdDtdBridgeCapabilities, + }).then(() => { + output.appendLine('[DTD] Registered rohd.resolveFrames service'); + }).catch((err) => { + output.appendLine(`[DTD] resolveFrames registration note: ${err}`); + }); + + rpcRequest('registerService', { + service: 'rohd', + method: 'queryModule', + capabilities: rohdDtdBridgeCapabilities, + }).then(() => { + output.appendLine('[DTD] Registered rohd.queryModule service'); + }).catch((err) => { + output.appendLine(`[DTD] queryModule registration note: ${err}`); + }); + + rpcRequest('registerService', { + service: 'rohd', + method: 'lookupSignal', + capabilities: rohdDtdBridgeCapabilities, + }).then(() => { + output.appendLine('[DTD] Registered rohd.lookupSignal service'); + }).catch((err) => { + output.appendLine(`[DTD] lookupSignal registration note: ${err}`); + }); + + resolve(true); + }); + + ws!.on('message', (data: Buffer | string) => { + handleMessage(data.toString()); + }); + + ws!.on('close', () => { + output.appendLine('[DTD] Connection closed'); + ws = undefined; + scheduleReconnect(); + }); + + ws!.on('error', (err: Error) => { + output.appendLine(`[DTD] Connection error: ${err.message}`); + ws = undefined; + connecting = false; + connectingUri = undefined; + resolve(false); + }); + }); + } catch (err) { + output.appendLine(`[DTD] Failed to connect: ${err}`); + connecting = false; + connectingUri = undefined; + return false; + } +} + +function scheduleReconnect(): void { + if (!dtdUri || reconnectTimer) return; + + // Exponential backoff: 2s, 4s, 8s, 16s, 32s max. + const delay = Math.min(2000 * 2 ** reconnectAttempts, 32000); + reconnectAttempts++; + + output.appendLine(`[DTD] Reconnecting in ${delay / 1000}s...`); + reconnectTimer = setTimeout(async () => { + reconnectTimer = undefined; + if (dtdUri) { + await connectToDtd(dtdUri); + } + }, delay); +} + +// --------------------------------------------------------------------------- +// DTD URI discovery +// --------------------------------------------------------------------------- + +/** + * Try to discover the DTD URI from known sources. + * + * Order of precedence: + * 1. `DART_TOOLING_DAEMON_URI` environment variable + * 2. VS Code setting `rohd.dtdUri` (for manual override) + * + * Note: The Dart extension API DTD URI is discovered asynchronously by + * debug_tracker.ts and fed to connectIfNeeded() when available. + */ +function discoverDtdUri(): string | undefined { + // Environment variable (set by IDE or debug launcher). + const envUri = process.env.DART_TOOLING_DAEMON_URI; + if (envUri) { + output.appendLine(`[DTD] URI from env: ${envUri}`); + return envUri; + } + + // VS Code setting. + const config = vscode.workspace.getConfiguration('rohd'); + const configUri = config.get('dtdUri'); + if (configUri) { + output.appendLine(`[DTD] URI from setting: ${configUri}`); + return configUri; + } + + output.appendLine('[DTD] No DTD URI discovered (debug_tracker will push later)'); + return undefined; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Whether the DTD bridge is connected. */ +export function isConnected(): boolean { + return ws?.readyState === 1; +} + +/** + * Connect to the DTD if not already connected. + * Called by debug_tracker when the DTD URI is discovered from the Dart + * extension API (which resolves after activation). + */ +export async function connectIfNeeded(uri: string): Promise { + if (isConnected()) { + return; // Already connected — nothing to do. + } + if (connecting && connectingUri === uri) { + return; // A connection to this URI is already in flight. + } + output.appendLine(`[DTD] Connecting via debug tracker: ${uri}`); + await connectToDtd(uri); +} + +/** + * Activate the DTD bridge. + * + * Discovers the DTD URI and connects. If the URI is not available + * at activation time, the bridge remains dormant and can be connected + * later via the `rohd.connectDtd` command. + */ +export async function activate(context: vscode.ExtensionContext): Promise { + // Register a command for manual DTD connection. + context.subscriptions.push( + vscode.commands.registerCommand('rohd.connectDtd', async () => { + const uri = await vscode.window.showInputBox({ + prompt: 'Enter the Dart Tooling Daemon WebSocket URI', + placeHolder: 'ws://127.0.0.1:...', + value: dtdUri ?? '', + }); + if (uri) { + await connectToDtd(uri); + } + }), + ); + + // Try automatic discovery. + const uri = discoverDtdUri(); + if (uri) { + await connectToDtd(uri); + } +} + +/** Clean up the DTD bridge. */ +export async function dispose(): Promise { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + dtdUri = undefined; + connecting = false; + connectingUri = undefined; + + if (ws) { + ws.close(); + ws = undefined; + } + + pendingRequests.clear(); + methods.clear(); +} diff --git a/rohd_extension/src/extension.ts b/rohd_extension/src/extension.ts new file mode 100644 index 000000000..53a762890 --- /dev/null +++ b/rohd_extension/src/extension.ts @@ -0,0 +1,113 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * extension.ts + * Main entry point for the ROHD VS Code extension. + * + * Provides: + * - ROHD Dart code snippets (carried forward from v0.0.5) + * - Cross-probe source navigation commands for ROHD viewer extensions + * + * Original snippets by: Yao Jing Quek + * Cross-probe navigation by: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; +import * as sourceNavigator from './source_navigator'; +import * as dtdBridge from './dtd_bridge'; +import * as debugTracker from './debug_tracker'; +import * as conditionalCompletions from './conditional_completions'; +import * as flcService from './flc_service'; + +export function activate(context: vscode.ExtensionContext): void { + console.log('ROHD extension is now active (v0.1.0)'); + + // Initialise the FLC service with the extension path so it can locate + // the compiled flc_lookup binary. + flcService.initialize(context.extensionPath); + + // Register cross-probe → editor navigation commands. + // These are invoked by ROHD viewer extensions (schematic, wave) via + // vscode.commands.executeCommand('rohd.openSourceLocation', ...). + sourceNavigator.registerCommands(context); + + // ── FLC commands — viewer extensions delegate here ────────────────────── + + // rohd.queryModule: resolve available source formats for a module. + // Args: { flcPath: string, module: string | null } + // Returns: ModuleInfo (extensionAvailable, module, formats, ...) + context.subscriptions.push( + vscode.commands.registerCommand( + 'rohd.queryModule', + (args: { flcPath: string; module: string | null }) => + flcService.queryModule(args.flcPath, args.module), + ), + ); + + // rohd.lookupSignal: resolve source frames for a signal. + // Args: { flcPath: string, module: string | null, signal: string, format?: string } + // Returns: SourceFrame[] + context.subscriptions.push( + vscode.commands.registerCommand( + 'rohd.lookupSignal', + (args: { flcPath: string; module: string | null; signal: string; format?: string }) => + flcService.lookupSignal(args.flcPath, args.module, args.signal, args.format), + ), + ); + + // rohd.resolveFlcPath: find the .flc.json sidecar for a document path. + // Args: { documentFsPath: string } + // Returns: string | null + context.subscriptions.push( + vscode.commands.registerCommand( + 'rohd.resolveFlcPath', + (args: { documentFsPath: string }) => + flcService.resolveFlcPath(args.documentFsPath), + ), + ); + + // Activate the DTD bridge for receiving cross-probe requests from + // the ROHD DevTools extension running in a remote iframe. + dtdBridge.activate(context); + + // Register with the debug adapter to automatically intercept Dart + // debug sessions, capture VM Service + DTD URIs, and print the + // consolidated banner. Replaces the old passive uri_forwarder. + debugTracker.activate(context); + + // Register context-aware ROHD completions if the user has opted in. + activateCompletionsIfEnabled(context); + + // Re-check when the setting changes at runtime. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('rohd.enableCompletions')) { + activateCompletionsIfEnabled(context); + } + }), + ); +} + +let completionsDisposable: vscode.Disposable | undefined; + +async function activateCompletionsIfEnabled( + context: vscode.ExtensionContext, +): Promise { + const config = vscode.workspace.getConfiguration('rohd'); + const enabled = config.get('enableCompletions', true); + + if (enabled && !completionsDisposable) { + completionsDisposable = conditionalCompletions.activate(context); + context.subscriptions.push(completionsDisposable); + } else if (!enabled && completionsDisposable) { + completionsDisposable.dispose(); + completionsDisposable = undefined; + } +} + +export function deactivate(): void { + debugTracker.deactivate(); + dtdBridge.dispose(); + sourceNavigator.dispose(); +} diff --git a/rohd_extension/src/flc_service.ts b/rohd_extension/src/flc_service.ts new file mode 100644 index 000000000..ff46245ea --- /dev/null +++ b/rohd_extension/src/flc_service.ts @@ -0,0 +1,515 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * flc_service.ts + * Centralised FLC (File-Location Cache) service for rohd_extension. + * + * Owns: + * - Resolving the .flc.json sidecar path from any document path + * - Querying available source formats (rohd, sv, …) for a module + * - Looking up signal/instance source frames from v5/v6 FLC JSON + * + * All FLC parsing logic that was previously duplicated across + * rohd-schematic-viewer/extension.js and rohd-wave-viewer/extension.ts + * now lives here. Those extensions delegate via VS Code commands: + * rohd.queryModule – returns ModuleInfo + * rohd.lookupSignal – returns SourceFrame[] + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const output = vscode.window.createOutputChannel('ROHD FLC'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FormatInfo { + available: boolean; + fileFound: boolean; + path: string | null; +} + +export interface ModuleFormats { + rohd?: FormatInfo; + sv?: FormatInfo; + [key: string]: FormatInfo | undefined; +} + +export interface ModuleInfo { + extensionAvailable: boolean; + module: string | null; + formats: ModuleFormats; + error?: string; + fstLoading: boolean; +} + +export interface SourceFrame { + file: string; + line: number; + col: number; + desc?: string; + type: string; +} + +interface FlcOutputPos { + type: string; + line: number; + col: number; +} + +interface FlcSymbolInfo { + name: string; + isInstance: boolean; + outputPositions: FlcOutputPos[]; + origName: string | null; +} + +// --------------------------------------------------------------------------- +// Initialisation +// --------------------------------------------------------------------------- + +let _extensionPath: string | undefined; + +/** Must be called from extension activate() before any other API. */ +export function initialize(extensionPath: string): void { + _extensionPath = extensionPath; + output.appendLine('[FlcService] Initialised. extensionPath=' + extensionPath); +} + +// --------------------------------------------------------------------------- +// Sidecar resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the .flc.json sidecar for any document path. + * + * Conventions supported: + * Foo.rohd.json → Foo.flc.json (schematic viewer) + * Foo.vcd → Foo.flc.json (wave viewer) + * Foo.fst → Foo.flc.json + * Foo.ghw → Foo.flc.json + * + * Returns the absolute path if the sidecar exists, otherwise null. + */ +export function resolveFlcPath(documentFsPath: string): string | null { + const dir = path.dirname(documentFsPath); + const base = path.basename(documentFsPath); + + // .rohd.json → .flc.json + const fromRohdJson = base.replace(/\.rohd\.json$/i, '.flc.json'); + if (fromRohdJson !== base) { + const p = path.join(dir, fromRohdJson); + return fs.existsSync(p) ? p : null; + } + + // .vcd / .fst / .ghw → .flc.json + const fromWave = base.replace(/\.(vcd|fst|ghw)$/i, '.flc.json'); + if (fromWave !== base) { + const p = path.join(dir, fromWave); + return fs.existsSync(p) ? p : null; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Module format query +// --------------------------------------------------------------------------- + +/** + * Return which source formats are available for [moduleName] in [flcPath]. + * + * Reads and parses the FLC JSON directly (no subprocess needed for metadata). + * Performs case-insensitive module name matching. + */ +export function queryModule(flcPath: string, moduleName: string | null): ModuleInfo { + if (!fs.existsSync(flcPath)) { + return { + extensionAvailable: true, + module: moduleName, + formats: {}, + error: 'FLC file not found: ' + flcPath, + fstLoading: false, + }; + } + + try { + const raw = fs.readFileSync(flcPath, 'utf8'); + const flcJson = JSON.parse(raw) as Record; + const modules = (flcJson['modules'] ?? {}) as Record; + const docDir = path.dirname(flcPath); + + // `files` entries (ROHD Dart sources) are stored relative to the package + // root (e.g. `.dart_tool/../lib/src/...`), NOT relative to the directory + // that holds the `.flc.json` (which is typically a `build/` output dir). + // Resolve against `packageRoot` when present, then fall back to `docDir`. + const packageRoot = + typeof flcJson['packageRoot'] === 'string' + ? (flcJson['packageRoot'] as string) + : null; + const resolveSourcePath = (relPath: string): string => { + const bases = packageRoot ? [packageRoot, docDir] : [docDir]; + for (const base of bases) { + const candidate = path.resolve(base, relPath); + if (fs.existsSync(candidate)) { + return candidate; + } + } + // None existed — return the best-guess canonical path (packageRoot-based + // when available) so the reported path is meaningful. + return path.resolve(bases[0], relPath); + }; + + // Accept exact match or case-insensitive prefix match. + let modData: Record | null = null; + if (moduleName && modules[moduleName]) { + modData = modules[moduleName] as Record; + } else if (moduleName) { + const lc = moduleName.toLowerCase(); + for (const [k, v] of Object.entries(modules)) { + if (k.toLowerCase() === lc || k.toLowerCase().startsWith(lc + '_')) { + modData = v as Record; + break; + } + } + } + + const formats: ModuleFormats = {}; + + if (modData) { + // ROHD Dart source: trie tree non-empty + at least one global .dart file. + const tree = modData['tree']; + const hasRohdTree = Array.isArray(tree) && tree.length > 0; + const globalFiles = (flcJson['files'] ?? []) as string[]; + const hasRohd = hasRohdTree && globalFiles.length > 0; + + if (hasRohd) { + const rohdFile = globalFiles.find(f => f.endsWith('.dart')); + const rohdPath = rohdFile ? resolveSourcePath(rohdFile) : null; + formats['rohd'] = { + available: true, + fileFound: rohdPath ? fs.existsSync(rohdPath) : false, + path: rohdPath, + }; + } + + // Output-language files (sv, sc, …) from outputFiles map. + // v6: Record (list per language). + // v5 legacy: Record. + const rawOutputFiles = + (modData['outputFiles'] ?? {}) as Record; + const outputFiles: Record = {}; + for (const [lang, val] of Object.entries(rawOutputFiles)) { + if (Array.isArray(val)) { + if (val.length > 0 && typeof val[0] === 'string') { + outputFiles[lang] = val[0] as string; + } + } else if (typeof val === 'string') { + outputFiles[lang] = val; + } + } + // Legacy single svFile field. + if (!outputFiles['sv'] && modData['svFile']) { + outputFiles['sv'] = modData['svFile'] as string; + } + for (const [lang, relPath] of Object.entries(outputFiles)) { + const absPath = path.resolve(docDir, relPath); + formats[lang] = { + available: true, + fileFound: fs.existsSync(absPath), + path: absPath, + }; + } + } else { + output.appendLine( + '[FlcService] queryModule: "' + moduleName + + '" not found. Available: ' + Object.keys(modules).slice(0, 10).join(', '), + ); + } + + return { + extensionAvailable: true, + module: moduleName, + formats, + fstLoading: false, + }; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + output.appendLine('[FlcService] queryModule error: ' + msg); + return { + extensionAvailable: true, + module: moduleName, + formats: {}, + error: 'Error reading FLC: ' + msg, + fstLoading: false, + }; + } +} + +// --------------------------------------------------------------------------- +// Signal lookup +// --------------------------------------------------------------------------- + +/** + * Look up source frames for [signalName] in [moduleName]. + * + * When [format] is provided, only frames of that type ('rohd', 'sv', 'sc', ...) + * are returned. Pass null/undefined to return all formats. + */ +export function lookupSignal( + flcPath: string, + moduleName: string | null, + signalName: string, + format?: string, +): SourceFrame[] { + if (!fs.existsSync(flcPath)) { + output.appendLine('[FlcService] lookupSignal: FLC not found: ' + flcPath); + return []; + } + + output.appendLine( + '[FlcService] lookupSignal: ' + flcPath + + ' module=' + (moduleName ?? '(any)') + + ' signal=' + signalName + + ' format=' + (format ?? 'all'), + ); + + try { + const raw = fs.readFileSync(flcPath, 'utf8'); + const flcJson = JSON.parse(raw) as Record; + const frames = lookupSignalInJson(flcJson, path.dirname(flcPath), moduleName, signalName); + const filtered = format ? frames.filter(f => f.type === format) : frames; + output.appendLine( + '[FlcService] lookupSignal: ' + filtered.length + ' frame(s)' + + (format ? ' after ' + format + ' filter' : ''), + ); + if (filtered.length === 0 && frames.length > 0 && format) { + output.appendLine( + '[FlcService] lookupSignal: available frame types=' + + Array.from(new Set(frames.map(f => f.type))).join(', '), + ); + } + return filtered; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + output.appendLine('[FlcService] lookupSignal failed: ' + msg); + return []; + } +} + +function lookupSignalInJson( + flcJson: Record, + flcDir: string, + moduleName: string | null, + signalName: string, +): SourceFrame[] { + const modules = asRecord(flcJson['modules']); + if (!modules) { return []; } + + const moduleNames = moduleName ? matchingModuleNames(modules, moduleName) : Object.keys(modules); + for (const modName of moduleNames) { + const modData = asRecord(modules[modName]); + if (!modData) { continue; } + const frames = lookupSignalInModule(flcJson, flcDir, modData, signalName); + if (frames.length > 0) { + return frames; + } + } + + if (moduleName) { + output.appendLine( + '[FlcService] lookupSignal: no match for ' + moduleName + '/' + signalName + + '. Available modules: ' + Object.keys(modules).slice(0, 10).join(', '), + ); + } + return []; +} + +function lookupSignalInModule( + flcJson: Record, + flcDir: string, + modData: Record, + signalName: string, +): SourceFrame[] { + const tree = modData['tree']; + if (!Array.isArray(tree)) { return []; } + + const files = Array.isArray(flcJson['files']) + ? (flcJson['files'] as unknown[]).filter((f): f is string => typeof f === 'string') + : []; + const outputFiles = getOutputFiles(modData); + + let origNameMatch: SourceFrame[] = []; + + const walkNode = (node: unknown[], pathFrames: string[]): SourceFrame[] => { + if (node.length === 0 || typeof node[0] !== 'string') { return []; } + const currentPath = [...pathFrames, node[0] as string]; + + for (let i = 1; i < node.length; i++) { + const elem = node[i]; + if (Array.isArray(elem)) { + const found = walkNode(elem, currentPath); + if (found.length > 0) { return found; } + } else if (typeof elem === 'string') { + const parsed = parseSymbolString(elem); + const frames = entryToFrames(parsed, currentPath, files, outputFiles, flcDir, signalName); + if (parsed.name === signalName) { + return frames; + } + if (parsed.origName === signalName && origNameMatch.length === 0) { + origNameMatch = frames; + } + } + } + + return []; + }; + + for (const rootNode of tree) { + if (!Array.isArray(rootNode)) { continue; } + const found = walkNode(rootNode, []); + if (found.length > 0) { return found; } + } + return origNameMatch; +} + +function entryToFrames( + symbol: FlcSymbolInfo, + pathFrames: string[], + files: string[], + outputFiles: Record, + flcDir: string, + signalName: string, +): SourceFrame[] { + const frames: SourceFrame[] = []; + + for (const frame of [...pathFrames].reverse()) { + const parts = frame.split(':'); + if (parts.length < 2) { continue; } + const fileIndex = Number.parseInt(parts[0], 10); + if (!Number.isInteger(fileIndex) || fileIndex < 0 || fileIndex >= files.length) { continue; } + frames.push({ + file: files[fileIndex], + line: Number.parseInt(parts[1], 10) || 1, + col: parts.length > 2 ? (Number.parseInt(parts[2], 10) || 1) : 1, + desc: signalName + ' [ROHD]', + type: 'rohd', + }); + } + + for (const pos of symbol.outputPositions) { + const outputFile = outputFiles[pos.type]; + if (!outputFile) { continue; } + frames.push({ + file: path.resolve(flcDir, outputFile), + line: pos.line, + col: pos.col, + desc: signalName + ' [' + pos.type.toUpperCase() + ']', + type: pos.type, + }); + } + + return frames; +} + +function getOutputFiles(modData: Record): Record { + const outputFiles: Record = {}; + const svFile = modData['svFile']; + if (typeof svFile === 'string') { + outputFiles['sv'] = svFile; + } + + const rawOutputFiles = asRecord(modData['outputFiles']); + if (!rawOutputFiles) { return outputFiles; } + + for (const [lang, val] of Object.entries(rawOutputFiles)) { + if (typeof val === 'string') { + outputFiles[lang] = val; + } else if (Array.isArray(val)) { + const first = val.find((item): item is string => typeof item === 'string'); + if (first) { + outputFiles[lang] = first; + } + } + } + return outputFiles; +} + +function parseSymbolString(symbol: string): FlcSymbolInfo { + const isInstance = symbol.startsWith('*'); + let rest = isInstance ? symbol.substring(1) : symbol; + + let origName: string | null = null; + const tildeIdx = rest.indexOf('~'); + if (tildeIdx >= 0) { + origName = rest.substring(tildeIdx + 1); + rest = rest.substring(0, tildeIdx); + } + + const outputPositions: FlcOutputPos[] = []; + const atIdx = rest.indexOf('@'); + if (atIdx >= 0) { + const positions = rest.substring(atIdx + 1); + rest = rest.substring(0, atIdx); + outputPositions.push(...parseOutputPositions(positions)); + } + + return { name: rest, isInstance, outputPositions, origName }; +} + +function parseOutputPositions(positions: string): FlcOutputPos[] { + const result: FlcOutputPos[] = []; + for (const group of positions.split(';')) { + if (!group) { continue; } + const entries = group.split(','); + let groupLang: string | null = null; + for (let i = 0; i < entries.length; i++) { + let part = entries[i]; + if (!part) { continue; } + if (i === 0) { + const segments = part.split(':'); + const firstIsTag = segments.length >= 3 && Number.isNaN(Number.parseInt(segments[0], 10)); + if (firstIsTag) { + groupLang = segments[0]; + part = segments.slice(1).join(':'); + } + } + const type = groupLang ?? 'sv'; + const segments = part.split(':'); + const lineText = segments.length >= 2 ? segments[segments.length - 2] : segments[0]; + const colText = segments.length >= 2 ? segments[segments.length - 1] : undefined; + result.push({ + type, + line: Number.parseInt(lineText, 10) || 1, + col: colText ? (Number.parseInt(colText, 10) || 1) : 1, + }); + } + } + return result; +} + +function matchingModuleNames(modules: Record, moduleName: string): string[] { + if (Object.prototype.hasOwnProperty.call(modules, moduleName)) { + return [moduleName]; + } + + const lower = moduleName.toLowerCase(); + const matches = Object.keys(modules).filter((name) => { + const candidate = name.toLowerCase(); + return candidate === lower || candidate.startsWith(lower + '_'); + }); + return matches; +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +} diff --git a/rohd_extension/src/source_navigator.ts b/rohd_extension/src/source_navigator.ts new file mode 100644 index 000000000..dc727e774 --- /dev/null +++ b/rohd_extension/src/source_navigator.ts @@ -0,0 +1,482 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * source_navigator.ts + * Cross-probe → VS Code editor navigation module. + * + * Receives source location requests from ROHD viewer extensions (schematic, + * wave) and navigates the VS Code editor to the corresponding file/line/col. + * Supports multi-frame stack traces with cycling. + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; + +const output = vscode.window.createOutputChannel('ROHD Source Navigator'); + +/** A single source location frame. */ +export interface SourceFrame { + /** File path (package-relative, e.g. `lib/src/foo.dart`). */ + file: string; + /** 1-based line number. */ + line: number; + /** 1-based column number. */ + col: number; + /** Optional description (e.g. function name from stack trace). */ + desc?: string; + /** Frame type: 'sv' for SystemVerilog, 'rohd' for ROHD Dart source. */ + type?: string; +} + +// --------------------------------------------------------------------------- +// Frame cycling state +// --------------------------------------------------------------------------- + +let currentFrames: SourceFrame[] = []; +let currentFrameIndex = 0; +let statusBarItem: vscode.StatusBarItem | undefined; +let statusBarTimeout: ReturnType | undefined; + +// Highlight decoration for the target symbol (yellow flash). +const highlightDecoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 213, 79, 0.35)', +}); +let highlightTimeout: ReturnType | undefined; + +// --------------------------------------------------------------------------- +// Public API — called via vscode.commands.executeCommand() +// --------------------------------------------------------------------------- + +/** + * Navigate to a single source location. + * + * @param args `{ file: string, line: number, col: number }` + */ +export async function openSourceLocation( + args: { file: string; line: number; col: number }, +): Promise { + if (!args || !args.file) { + vscode.window.showWarningMessage('ROHD: No source location provided.'); + return; + } + // Clear any previous frame cycling state. + currentFrames = [{ file: args.file, line: args.line, col: args.col }]; + currentFrameIndex = 0; + hideStatusBar(); + await navigateToFrame(currentFrames[0]); +} + +/** + * Navigate to the first of multiple source location frames and enable + * cycling through them. + * + * @param args `{ frames: SourceFrame[], index?: number }` + */ +export async function openSourceLocations( + args: { frames: SourceFrame[]; index?: number }, +): Promise { + if (!args || !args.frames || args.frames.length === 0) { + vscode.window.showWarningMessage('ROHD: No source locations provided.'); + return; + } + currentFrames = args.frames; + currentFrameIndex = args.index ?? 0; + if (currentFrameIndex < 0 || currentFrameIndex >= currentFrames.length) { + currentFrameIndex = 0; + } + updateStatusBar(); + + // Navigate to the primary frame. + await navigateToFrame(currentFrames[currentFrameIndex]); + + // Also open the first frame of each other type (e.g. SV alongside ROHD) + // so both files are visible simultaneously. + const openedTypes = new Set([currentFrames[currentFrameIndex].type || 'rohd']); + for (const frame of currentFrames) { + const t = frame.type || 'rohd'; + if (!openedTypes.has(t)) { + openedTypes.add(t); + await navigateToFrame(frame, true); + } + } +} + +/** Advance to the next frame in the current frame list. */ +export async function nextSourceLocation(): Promise { + if (currentFrames.length === 0) { return; } + currentFrameIndex = (currentFrameIndex + 1) % currentFrames.length; + updateStatusBar(); + await navigateToFrame(currentFrames[currentFrameIndex]); +} + +/** Go back to the previous frame in the current frame list. */ +export async function prevSourceLocation(): Promise { + if (currentFrames.length === 0) { return; } + currentFrameIndex = + (currentFrameIndex - 1 + currentFrames.length) % currentFrames.length; + updateStatusBar(); + await navigateToFrame(currentFrames[currentFrameIndex]); +} + +// --------------------------------------------------------------------------- +// Navigation implementation +// --------------------------------------------------------------------------- + +/** + * Open (or reuse) an editor tab for the given frame and scroll to the + * target line. Tries multiple candidate paths until one succeeds. + */ +async function navigateToFrame(frame: SourceFrame, preserveFocus = false): Promise { + const candidates = resolveCandidates(frame.file); + if (candidates.length === 0) { + vscode.window.showWarningMessage( + `ROHD: Could not resolve file: ${frame.file}`, + ); + return; + } + + // 0-based position from 1-based FLC data. + const line = Math.max(0, frame.line - 1); + const col = Math.max(0, frame.col - 1); + const pos = new vscode.Position(line, col); + const range = new vscode.Range(pos, pos); + + for (const uri of candidates) { + try { + const doc = await vscode.workspace.openTextDocument(uri); + const docUriStr = doc.uri.toString(); + + // Reuse an existing editor tab for this document if one is already + // open, instead of always opening a new split. Compare against + // the document's canonical URI (not the candidate URI which may + // contain unresolved `..` segments). + let viewColumn = vscode.ViewColumn.Beside; + for (const tab of vscode.window.tabGroups.all.flatMap(g => g.tabs)) { + const tabInput = tab.input; + if (tabInput instanceof vscode.TabInputText && + tabInput.uri.toString() === docUriStr) { + viewColumn = tab.group.viewColumn; + break; + } + } + + const editor = await vscode.window.showTextDocument(doc, { + viewColumn, + preserveFocus, + selection: range, + }); + + editor.revealRange( + new vscode.Range(pos, pos), + vscode.TextEditorRevealType.InCenterIfOutsideViewport, + ); + + // Flash-highlight the symbol at the target position. + flashHighlight(editor, line, col); + + output.appendLine( + `Navigated to ${frame.file}:${frame.line}:${frame.col} (resolved: ${uri.fsPath})`, + ); + return; // Success — stop trying candidates. + } catch { + // This candidate didn't work; try the next one. + continue; + } + } + + // All candidates failed. + vscode.window.showWarningMessage( + `ROHD: Could not find file: ${frame.file}`, + ); + output.appendLine( + `Failed to resolve ${frame.file} (tried ${candidates.length} candidates)`, + ); +} + +/** + * Normalize a file path by collapsing `.` and `..` segments. + * + * FLC paths from SourceTraceRegistry often contain `.dart_tool/../lib/...` + * which needs collapsing before resolution. + */ +function normalizePath(filePath: string): string { + const isAbsolute = filePath.startsWith('/'); + const parts = filePath.split('/'); + const resolved: string[] = []; + for (const part of parts) { + if (part === '.' || part === '') { + continue; + } else if (part === '..' && resolved.length > 0 && resolved[resolved.length - 1] !== '..') { + resolved.pop(); + } else { + resolved.push(part); + } + } + const joined = resolved.join('/'); + return isAbsolute ? '/' + joined : joined; +} + +/** + * Generate candidate URIs for a package-relative file path. + * + * Tries (in order): + * 1. Normalized path relative to each workspace folder + * 2. Normalized path relative to parent directories of each workspace + * folder (up to 4 levels — covers typical layouts where the ROHD + * package root is above the extension subdirectory) + * 3. As an absolute path + */ +function resolveCandidates(filePath: string): vscode.Uri[] { + const normalized = normalizePath(filePath); + const candidates: vscode.Uri[] = []; + const folders = vscode.workspace.workspaceFolders; + + if (folders) { + for (const folder of folders) { + // Direct: workspace root + normalized path + candidates.push(vscode.Uri.joinPath(folder.uri, normalized)); + + // SV files are often generated into a build/ directory. + candidates.push(vscode.Uri.joinPath(folder.uri, 'build', normalized)); + + // Walk up parent directories (the ROHD package root may be above + // the workspace folder, e.g. merged/ vs merged/rohd_devtools_extension/rohd-schematic-viewer/) + let parent = folder.uri; + for (let i = 0; i < 4; i++) { + parent = vscode.Uri.joinPath(parent, '..'); + candidates.push(vscode.Uri.joinPath(parent, normalized)); + candidates.push(vscode.Uri.joinPath(parent, 'build', normalized)); + } + } + } + + // Absolute path fallback. + if (normalized.startsWith('/')) { + candidates.push(vscode.Uri.file(normalized)); + } + + // Also try the original (un-normalized) path in case it's already correct. + if (normalized !== filePath && folders) { + for (const folder of folders) { + candidates.push(vscode.Uri.joinPath(folder.uri, filePath)); + } + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// Status bar +// --------------------------------------------------------------------------- + +function updateStatusBar(): void { + if (currentFrames.length <= 1) { + hideStatusBar(); + return; + } + if (!statusBarItem) { + statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + statusBarItem.command = 'rohd.nextSourceLocation'; + statusBarItem.tooltip = 'Click to cycle through source frames'; + } + const frame = currentFrames[currentFrameIndex]; + const shortFile = frame.file.split('/').pop() ?? frame.file; + const typeTag = frame.type === 'sv' ? 'SV' : frame.type === 'rohd' ? 'ROHD' : 'Source'; + const desc = frame.desc ? ` ${frame.desc}` : ''; + statusBarItem.text = `$(source-control) ${typeTag} ${currentFrameIndex + 1}/${currentFrames.length}: ${shortFile}:${frame.line}${desc}`; + statusBarItem.show(); + + // Auto-hide after 15 seconds. + if (statusBarTimeout) { clearTimeout(statusBarTimeout); } + statusBarTimeout = setTimeout(() => hideStatusBar(), 15000); +} + +function hideStatusBar(): void { + statusBarItem?.hide(); + if (statusBarTimeout) { + clearTimeout(statusBarTimeout); + statusBarTimeout = undefined; + } +} + +// --------------------------------------------------------------------------- +// Highlight flash +// --------------------------------------------------------------------------- + +function flashHighlight(editor: vscode.TextEditor, line: number, col: number): void { + if (highlightTimeout) { clearTimeout(highlightTimeout); } + + const doc = editor.document; + const pos = new vscode.Position(line, col); + + // Try to get the word range at the column position (symbol highlight). + const wordRange = doc.getWordRangeAtPosition(pos); + + // Use the word range if found, otherwise highlight from column to end of line. + const highlightRange = wordRange + ?? new vscode.Range(pos, doc.lineAt(line).range.end); + + editor.setDecorations(highlightDecoration, [highlightRange]); + + // Remove highlight after 60 seconds. + highlightTimeout = setTimeout(() => { + editor.setDecorations(highlightDecoration, []); + }, 60000); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/** Register all commands. Call from extension activate(). */ +export function registerCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('rohd.openSourceLocation', openSourceLocation), + vscode.commands.registerCommand('rohd.openSourceLocations', openSourceLocations), + vscode.commands.registerCommand('rohd.nextSourceLocation', nextSourceLocation), + vscode.commands.registerCommand('rohd.prevSourceLocation', prevSourceLocation), + ); + output.appendLine('ROHD Source Navigator commands registered.'); +} + +/** Clean up. Call from extension deactivate(). */ +export function dispose(): void { + hideStatusBar(); + statusBarItem?.dispose(); + highlightDecoration.dispose(); +} + +// --------------------------------------------------------------------------- +// Frame enrichment — resolve enclosing method names via Document Symbols +// --------------------------------------------------------------------------- + +/** A frame enriched with its enclosing method/class names. */ +export interface EnrichedFrame extends SourceFrame { + /** Enclosing method/function name (e.g. `"build"`). */ + methodName?: string; + /** Enclosing class name (e.g. `"Serializer"`). */ + className?: string; + /** Human-readable label: `"Serializer.build() — serializer.dart:55"`. */ + label?: string; +} + +/** + * Resolve the enclosing method name for a source location using the + * VS Code Document Symbol Provider (backed by the Dart language server). + */ +async function resolveEnclosingSymbol( + uri: vscode.Uri, + line: number, + col: number, +): Promise<{ methodName?: string; className?: string }> { + try { + const symbols = await vscode.commands.executeCommand( + 'vscode.executeDocumentSymbolProvider', + uri, + ); + if (!symbols || symbols.length === 0) { + return {}; + } + + const pos = new vscode.Position(Math.max(0, line - 1), Math.max(0, col - 1)); + let methodName: string | undefined; + let className: string | undefined; + + // Walk the symbol tree depth-first, tracking the innermost containing + // function/method and its parent class. + function walk(syms: vscode.DocumentSymbol[], parentClass?: string): void { + for (const sym of syms) { + if (!sym.range.contains(pos)) { continue; } + + if (sym.kind === vscode.SymbolKind.Class || + sym.kind === vscode.SymbolKind.Enum) { + className = sym.name; + walk(sym.children, sym.name); + } else if ( + sym.kind === vscode.SymbolKind.Method || + sym.kind === vscode.SymbolKind.Function || + sym.kind === vscode.SymbolKind.Constructor + ) { + methodName = sym.name; + if (parentClass) { className = parentClass; } + // Continue into children in case there's a nested function. + walk(sym.children, parentClass); + } else { + walk(sym.children, parentClass); + } + } + } + + walk(symbols); + return { methodName, className }; + } catch { + return {}; + } +} + +/** + * Enrich a list of frames with enclosing method/class names. + * + * Opens each document (lazily — no visible editor tab) to query the + * Document Symbol Provider. Returns an enriched copy of each frame. + */ +export async function resolveFrames( + frames: SourceFrame[], +): Promise { + const enriched: EnrichedFrame[] = []; + + for (const frame of frames) { + const candidates = resolveCandidates(frame.file); + let methodName: string | undefined; + let className: string | undefined; + let resolved = false; + + for (const uri of candidates) { + try { + // Open the document without showing it — this triggers the + // language server to analyse it if not already cached. + await vscode.workspace.openTextDocument(uri); + const result = await resolveEnclosingSymbol(uri, frame.line, frame.col); + methodName = result.methodName; + className = result.className; + resolved = true; + break; + } catch { + continue; + } + } + + // Build human-readable label. + const shortFile = frame.file.split('/').pop() ?? frame.file; + let label: string; + if (className && methodName) { + label = `${className}.${methodName}() — ${shortFile}:${frame.line}`; + } else if (methodName) { + label = `${methodName}() — ${shortFile}:${frame.line}`; + } else if (className) { + label = `${className} — ${shortFile}:${frame.line}`; + } else { + label = `${shortFile}:${frame.line}`; + } + + enriched.push({ + ...frame, + methodName, + className, + label, + }); + + if (!resolved) { + output.appendLine( + `[resolveFrames] Could not resolve symbols for ${frame.file}:${frame.line}`, + ); + } + } + + return enriched; +} diff --git a/rohd_extension/src/uri_forwarder.ts b/rohd_extension/src/uri_forwarder.ts new file mode 100644 index 000000000..62336f32d --- /dev/null +++ b/rohd_extension/src/uri_forwarder.ts @@ -0,0 +1,295 @@ +/* --------------------------------------------------------------------------- + * Copyright (C) 2026 Intel Corporation. + * SPDX-License-Identifier: BSD-3-Clause + * + * uri_forwarder.ts + * Resolves and displays DTD and VM Service URIs with port-forwarding + * awareness. When running inside a dev container or remote session, + * VS Code's `asExternalUri` may map container-internal ports to + * host-visible ports. This module detects that and shows both the + * original and forwarded URIs so they can be pasted into the ROHD + * DevTools GUI. + * + * Author: Desmond Kirkpatrick + * --------------------------------------------------------------------------- */ + +import * as vscode from 'vscode'; + +const EXTENSION_VERSION = '0.1.0'; + +let outputChannel: vscode.OutputChannel; + +/** Map of session id → last known VM Service URI (container-internal). */ +const sessionVmUris = new Map(); + +/** Latest original DTD URI (container-internal). */ +let originalDtdUri: string | undefined; + +/** Latest forwarded DTD URI, undefined if no forwarding occurred. */ +let forwardedDtdUri: string | undefined; + +// --------------------------------------------------------------------------- +// URI helpers +// --------------------------------------------------------------------------- + +/** + * Given a raw WS URI, resolve via `vscode.env.asExternalUri` to get the + * port-forwarded equivalent. Returns the forwarded URI string, or + * `undefined` if no forwarding occurred (i.e. authority is unchanged). + */ +async function resolveForwardedUri( + rawWsUri: string, +): Promise { + try { + const httpUri = rawWsUri + .replace(/^ws:\/\//, 'http://') + .replace(/^wss:\/\//, 'https://'); + + const parsed = vscode.Uri.parse(httpUri); + const resolved = await vscode.env.asExternalUri(parsed); + + const scheme = resolved.scheme === 'https' ? 'wss' : 'ws'; + const forwarded = `${scheme}://${resolved.authority}${parsed.path}`; + + if (resolved.authority !== parsed.authority) { + return forwarded; + } + return undefined; + } catch { + return undefined; + } +} + +/** Normalize a URI to ws:// scheme; optionally ensure a /ws suffix. */ +function normalizeWsUri(uri: string, ensureWsSuffix: boolean): string { + let u = uri; + if (u.startsWith('http://')) { + u = u.replace('http://', 'ws://'); + } else if (u.startsWith('https://')) { + u = u.replace('https://', 'wss://'); + } + if (ensureWsSuffix && !u.endsWith('/ws')) { + u = u.replace(/\/?$/, '/ws'); + } + return u; +} + +// --------------------------------------------------------------------------- +// Dart extension API typings (subset) +// --------------------------------------------------------------------------- + +interface DartExtensionApi { + dtdUri?: string; + onDtdUriChanged?: ( + listener: (uri: string | undefined) => void, + thisArgs?: unknown, + disposables?: vscode.Disposable[], + ) => vscode.Disposable; +} + +// --------------------------------------------------------------------------- +// DTD URI handling +// --------------------------------------------------------------------------- + +async function processDtdUri(rawUri: string): Promise { + const wsUri = normalizeWsUri(rawUri, false); + originalDtdUri = wsUri; + outputChannel.appendLine(`[DTD] Original: ${wsUri}`); + + const forwarded = await resolveForwardedUri(wsUri); + forwardedDtdUri = forwarded; + if (forwarded) { + outputChannel.appendLine(`[DTD] Forwarded: ${forwarded}`); + } +} + +function watchDtdUri(context: vscode.ExtensionContext): void { + const dartExt = vscode.extensions.getExtension('dart-code.dart-code'); + if (!dartExt) { + outputChannel.appendLine( + '[DTD] Dart extension not found — DTD URI detection disabled.', + ); + return; + } + + function handleApi(api: DartExtensionApi): void { + if (api.dtdUri) { + processDtdUri(api.dtdUri).catch((err) => { + outputChannel.appendLine(`[DTD] Error: ${err}`); + }); + } + if (api.onDtdUriChanged) { + const disposable = api.onDtdUriChanged((uri) => { + if (uri) { + processDtdUri(uri).catch((err) => { + outputChannel.appendLine(`[DTD] Error: ${err}`); + }); + } + }); + if (disposable) { + context.subscriptions.push(disposable); + } + } + } + + if (dartExt.isActive) { + handleApi(dartExt.exports as DartExtensionApi); + } else { + dartExt.activate().then( + (api) => handleApi(api as DartExtensionApi), + (err) => + outputChannel.appendLine( + `[DTD] Dart extension activation failed: ${err}`, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Consolidated output +// --------------------------------------------------------------------------- + +/** + * Print a formatted block with the ROHD version, FLC info, and the + * DTD / VM Service URIs (original + forwarded when port differs). + */ +async function printConsolidatedBlock( + vmOriginal: string, + vmForwarded: string | undefined, + session: vscode.DebugSession, +): Promise { + const lines: string[] = []; + + lines.push(`ROHD ${EXTENSION_VERSION}: Extension loaded for FLC crossprobing.`); + lines.push(''); + + if (originalDtdUri) { + lines.push('DTD:'); + lines.push(` URI: ${originalDtdUri}`); + if (forwardedDtdUri) { + lines.push(` Fwd: ${forwardedDtdUri}`); + } + } + + lines.push('VM:'); + lines.push(` URI: ${vmOriginal}`); + if (vmForwarded) { + lines.push(` Fwd: ${vmForwarded}`); + } + + const banner = '═'.repeat(60); + const block = [banner, ...lines, banner].join('\n'); + + // Debug console + vscode.debug.activeDebugConsole.appendLine(''); + vscode.debug.activeDebugConsole.appendLine(block); + + // Output channel (persists across sessions) + outputChannel.appendLine(''); + outputChannel.appendLine(block); + + // Show the most useful forwarded URI in a popup for quick copy + const popupUri = forwardedDtdUri ?? originalDtdUri; + if (popupUri) { + const action = await vscode.window.showInformationMessage( + `DTD: ${popupUri}`, + 'Copy', + ); + if (action === 'Copy') { + await vscode.env.clipboard.writeText(popupUri); + vscode.window.showInformationMessage('DTD URI copied to clipboard.'); + } + } +} + +// --------------------------------------------------------------------------- +// VM Service URI handling +// --------------------------------------------------------------------------- + +async function processVmServiceUri( + rawUri: string, + session: vscode.DebugSession, +): Promise { + const vmUri = normalizeWsUri(rawUri, true); + sessionVmUris.set(session.id, vmUri); + + outputChannel.appendLine(`[VM] Original: ${vmUri}`); + + const forwarded = await resolveForwardedUri(vmUri); + await printConsolidatedBlock(vmUri, forwarded, session); +} + +// --------------------------------------------------------------------------- +// Public API — called from extension.ts +// --------------------------------------------------------------------------- + +export function activate(context: vscode.ExtensionContext): void { + outputChannel = vscode.window.createOutputChannel('ROHD URI Forwarder'); + + // Start watching for the DTD URI early — it resolves before the + // debug session starts so it will be ready when we print. + watchDtdUri(context); + + // Listen for the DAP custom event "dart.debuggerUris" + context.subscriptions.push( + vscode.debug.onDidReceiveDebugSessionCustomEvent((e) => { + if (e.event !== 'dart.debuggerUris') { + return; + } + + const uri = e.body?.vmServiceUri as string | undefined; + if (!uri) { + outputChannel.appendLine( + '[URI Forwarder] dart.debuggerUris event received but no vmServiceUri in body.', + ); + return; + } + + outputChannel.appendLine( + `[URI Forwarder] Received dart.debuggerUris for session "${e.session.name}".`, + ); + + processVmServiceUri(uri, e.session).catch((err) => { + outputChannel.appendLine(`[URI Forwarder] Error: ${err}`); + }); + }), + ); + + // Clean up stored URIs when sessions end + context.subscriptions.push( + vscode.debug.onDidTerminateDebugSession((session) => { + sessionVmUris.delete(session.id); + }), + ); + + // Manual command: re-resolve and re-print for the active session + context.subscriptions.push( + vscode.commands.registerCommand('rohd.showForwardedUris', async () => { + const session = vscode.debug.activeDebugSession; + if (!session) { + vscode.window.showWarningMessage('No active debug session.'); + return; + } + + const uri = sessionVmUris.get(session.id); + if (!uri) { + vscode.window.showWarningMessage( + 'VM Service URI not yet available for this session. ' + + 'It will be displayed automatically once the Dart VM starts.', + ); + return; + } + + await processVmServiceUri(uri, session); + }), + ); + + outputChannel.appendLine('[URI Forwarder] Activated.'); +} + +export function deactivate(): void { + sessionVmUris.clear(); + originalDtdUri = undefined; + forwardedDtdUri = undefined; + outputChannel?.dispose(); +} diff --git a/rohd_extension/tool/install.sh b/rohd_extension/tool/install.sh new file mode 100755 index 000000000..bb2f6d235 --- /dev/null +++ b/rohd_extension/tool/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# install.sh — Build and install the ROHD VS Code extension. +# +# Usage: +# ./tool/install.sh # build + install +# ./tool/install.sh --skip-build # install existing .vsix only +# +# Requires: node ≥ 18, npm, code CLI +# --------------------------------------------------------------------------- +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +VERSION=$(node -p "require('$EXT_DIR/package.json').version") +VSIX="$EXT_DIR/rohd-${VERSION}.vsix" + +# ── Ensure Node ≥ 18 is available ── +if ! command -v node &>/dev/null; then + if [[ -d "$HOME/.nvm/versions/node" ]]; then + NODE_DIR=$(ls -d "$HOME/.nvm/versions/node"/v2* 2>/dev/null | sort -V | tail -1) + [[ -z "$NODE_DIR" ]] && NODE_DIR=$(ls -d "$HOME/.nvm/versions/node"/v1[89]* 2>/dev/null | sort -V | tail -1) + if [[ -n "$NODE_DIR" ]]; then + export PATH="$NODE_DIR/bin:$PATH" + echo "Using node from $NODE_DIR" + fi + fi +fi + +node_ver=$(node --version 2>/dev/null || echo "none") +echo "Node: $node_ver" + +# ── Build ── +if [[ "${1:-}" != "--skip-build" ]]; then + echo "── Installing npm dependencies ──" + cd "$EXT_DIR" + npm install --no-audit --no-fund + + echo "── Compiling TypeScript ──" + npx tsc + + echo "── Packaging VSIX ──" + rm -f "$EXT_DIR"/rohd-*.vsix + echo y | npx @vscode/vsce package --no-dependencies +fi + +# ── Install ── +if [[ ! -f "$VSIX" ]]; then + echo "ERROR: $VSIX not found. Run without --skip-build first." >&2 + exit 1 +fi + +echo "── Installing $VSIX ──" +code --install-extension "$VSIX" --force + +echo "" +echo "Done — ROHD extension v${VERSION} installed." +echo "Reload the VS Code window to activate." diff --git a/rohd_extension/tsconfig.json b/rohd_extension/tsconfig.json new file mode 100644 index 000000000..ec2434dd0 --- /dev/null +++ b/rohd_extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "out"] +} diff --git a/test/array_collapsing_test.dart b/test/array_collapsing_test.dart index 1e01f0431..a287cba6f 100644 --- a/test/array_collapsing_test.dart +++ b/test/array_collapsing_test.dart @@ -65,6 +65,681 @@ class ArrayWithShuffledAssignment extends Module { } } +/// Partially assigns three elements of an internal array from an input array. +/// +/// When reversed is false, the assignments are contiguous and same-offset, +/// so they can collapse into a range-to-range assignment. When reversed is +/// true, the assignments are intentionally not same-offset and must remain +/// expanded. +class PartialArrayRangeAssignment extends Module { + PartialArrayRangeAssignment({bool reversed = false}) + : super(name: 'partial_array_range_assignment') { + final src = addInputArray('src', LogicArray([6], 1), dimensions: [6]); + final dst = LogicArray([6], 1, name: 'dst'); + + for (var dstIndex = 2; dstIndex <= 4; dstIndex++) { + final srcIndex = reversed ? 6 - dstIndex : dstIndex; + dst.elements[dstIndex] <= src.elements[srcIndex]; + } + + addOutput('y', width: 6) <= dst.elements.rswizzle(); + } +} + +/// Partially assigns a range through an intermediate array. The two range +/// assignments should compose so the intermediate array can be pruned. +class ChainedPartialArrayRangeAssignment extends Module { + ChainedPartialArrayRangeAssignment({ + bool exposeIntermediate = false, + Naming? intermediateNaming = Naming.mergeable, + }) : super(name: 'chained_partial_array_range_assignment') { + final src = addInputArray('src', LogicArray([6], 1), dimensions: [6]); + final intermediate = + LogicArray([6], 1, name: 'intermediate', naming: intermediateNaming); + final dst = LogicArray([6], 1, name: 'dst'); + + for (var index = 2; index <= 4; index++) { + intermediate.elements[index] <= src.elements[index]; + dst.elements[index] <= intermediate.elements[index]; + } + + addOutput('y', width: 6) <= dst.elements.rswizzle(); + if (exposeIntermediate) { + addOutput('z', width: 6) <= intermediate.elements.rswizzle(); + } + } +} + +/// Drives every leaf of a small multidimensional array from literal constants. +/// Constant sources are not range bases, so range collapsing must not try to +/// treat these as array-to-array range assignments. +class ConstantLeafArrayAssignment extends Module { + ConstantLeafArrayAssignment({ + List values = const [0, 0, 0, 0], + super.name = 'constant_leaf_array_assignment', + }) { + final banana = LogicArray([2, 2], 1, name: 'banana'); + + for (var index = 0; index < banana.leafElements.length; index++) { + banana.leafElements[index] <= Const(values[index]); + } + + addOutput('y', width: banana.width) <= banana.leafElements.rswizzle(); + } +} + +/// Partially assigns a range through two intermediate arrays. The range +/// composition pass should iterate until both intermediates are gone. +class ThreeDeepChainedPartialArrayRangeAssignment extends Module { + ThreeDeepChainedPartialArrayRangeAssignment() + : super(name: 'three_deep_chained_partial_array_range_assignment') { + final src = addInputArray('src', LogicArray([6], 1), dimensions: [6]); + final intermediate0 = + LogicArray([6], 1, name: 'intermediate0', naming: Naming.mergeable); + final intermediate1 = + LogicArray([6], 1, name: 'intermediate1', naming: Naming.mergeable); + final dst = LogicArray([6], 1, name: 'dst'); + + for (var index = 2; index <= 4; index++) { + intermediate0.elements[index] <= src.elements[index]; + intermediate1.elements[index] <= intermediate0.elements[index]; + dst.elements[index] <= intermediate1.elements[index]; + } + + addOutput('y', width: 6) <= dst.elements.rswizzle(); + } +} + +/// Passes a partial packed-array range through many mergeable intermediates. +class LongChainedPartialArrayRangeAssignment extends Module { + /// The number of intermediate range stages. + final int length; + + LongChainedPartialArrayRangeAssignment({this.length = 256}) + : super(name: 'long_chained_partial_array_range_assignment') { + final src = addInputArray('src', LogicArray([6], 1), dimensions: [6]); + var previous = src; + for (var stage = 0; stage < length; stage++) { + final intermediate = LogicArray( + [6], + 1, + name: 'intermediate$stage', + naming: Naming.mergeable, + ); + for (var index = 2; index <= 4; index++) { + intermediate.elements[index] <= previous.elements[index]; + } + previous = intermediate; + } + + final dst = LogicArray([6], 1, name: 'dst'); + for (var index = 2; index <= 4; index++) { + dst.elements[index] <= previous.elements[index]; + } + addOutput('y', width: 6) <= dst.elements.rswizzle(); + } +} + +/// Partially assigns an offset source range through an intermediate, then reads +/// only a subrange of the intermediate. +class ChainedSubrangeArrayRangeAssignment extends Module { + ChainedSubrangeArrayRangeAssignment() + : super(name: 'chained_subrange_array_range_assignment') { + final src = addInputArray('src', LogicArray([8], 1), dimensions: [8]); + final intermediate = + LogicArray([8], 1, name: 'intermediate', naming: Naming.mergeable); + final dst = LogicArray([8], 1, name: 'dst'); + + for (var index = 1; index <= 5; index++) { + intermediate.elements[index] <= src.elements[index + 2]; + } + for (var index = 2; index <= 3; index++) { + dst.elements[index] <= intermediate.elements[index + 1]; + } + + addOutput('y', width: 8) <= dst.elements.rswizzle(); + } +} + +/// Partially assigns array elements from bits of a flat input bus. +class PartialBusToArrayRangeAssignment extends Module { + PartialBusToArrayRangeAssignment({ + bool reversed = false, + int numUnpackedDimensions = 0, + }) : super(name: 'partial_bus_to_array_range_assignment') { + final src = addInput('src', Logic(width: 8), width: 8); + final dst = LogicArray([8], 1, + name: 'dst', numUnpackedDimensions: numUnpackedDimensions); + + for (var dstIndex = 2; dstIndex <= 5; dstIndex++) { + final srcIndex = reversed ? 7 - dstIndex : dstIndex; + dst.elements[dstIndex] <= src[srcIndex]; + } + + addOutput('y', width: 8) <= dst.leafElements.rswizzle(); + } +} + +/// Assigns a full array range into a flat bus through [Logic.assignSubset]. +class ArrayToBusAssignSubsetRangeAssignment extends Module { + ArrayToBusAssignSubsetRangeAssignment({bool partial = false}) + : super(name: 'array_to_bus_assign_subset_range_assignment') { + final src = addInputArray('src', LogicArray([8], 1), dimensions: [8]); + final dst = Logic(width: 8, name: 'dst'); + + final start = partial ? 2 : 0; + final end = partial ? 5 : 7; + for (var index = start; index <= end; index++) { + dst.assignSubset([src.elements[index]], start: index); + } + + addOutput('y', width: 8) <= dst; + } +} + +/// Assigns a flat bus range into another flat bus through a temporary slice. +class BusSliceTemporaryToAssignSubsetRangeAssignment extends Module { + BusSliceTemporaryToAssignSubsetRangeAssignment({ + bool receiverIsOutput = false, + bool driveLowBits = true, + }) : super(name: 'bus_slice_temporary_to_assign_subset_range_assignment') { + final src = addInput('src', Logic(width: 16), width: 16); + final dst = receiverIsOutput + ? addOutput('y', width: 8) + : Logic(width: 8, name: 'dst'); + final srcSlice = + Logic(width: 4, name: 'src_slice', naming: Naming.mergeable); + + if (driveLowBits) { + for (var index = 0; index < 4; index++) { + dst.assignSubset([src[index]], start: index); + } + } + srcSlice <= src.getRange(11, 15); + for (var index = 0; index < 4; index++) { + dst.assignSubset([srcSlice[index]], start: index + 4); + } + + if (!receiverIsOutput) { + addOutput('y', width: 8) <= dst; + } + } +} + +/// Uses the same selected bus bits both for [Logic.assignSubset] and for other +/// submodule inputs. Range collapse must not delete the bit-select helpers +/// needed by those other consumers. +class BusSubsetBitsWithExtraConsumers extends Module { + BusSubsetBitsWithExtraConsumers() + : super(name: 'bus_subset_bits_with_extra_consumers') { + final src = addInput('src', Logic(width: 8), width: 8); + final dst = Logic(width: 4, name: 'dst'); + final inverted = []; + + for (var index = 0; index < 4; index++) { + final selected = src[index + 2]; + dst.assignSubset([selected], start: index); + inverted.add(InverterMod(selected).o); + } + + addOutput('y', width: 4) <= dst; + addOutput('z', width: 4) <= inverted.rswizzle(); + } +} + +/// Assigns two sparse contiguous bus ranges into a flat bus through +/// [Logic.assignSubset]. +class SparseBusRunsToAssignSubsetRangeAssignment extends Module { + SparseBusRunsToAssignSubsetRangeAssignment() + : super(name: 'sparse_bus_runs_to_assign_subset_range_assignment') { + final srcA = addInput('srcA', Logic(width: 32), width: 32); + final srcB = addInput('srcB', Logic(width: 16), width: 16); + final dst = Logic(width: 64, name: 'dst'); + + for (var index = 0; index < 12; index++) { + dst.assignSubset([srcA[index + 4]], start: index + 20); + } + for (var index = 0; index < 12; index++) { + dst.assignSubset([srcB[index]], start: index + 44); + } + + addOutput('y', width: 64) <= dst; + } +} + +/// Builds a packed bus from a live lower range and a constant-backed upper +/// range, then passes the whole bus to a child input. +class TiedRangeToAssignSubsetRangeAssignment extends Module { + TiedRangeToAssignSubsetRangeAssignment({ + Naming tieNaming = Naming.mergeable, + Naming busNaming = Naming.mergeable, + }) : super(name: 'tied_range_to_assign_subset_range_assignment') { + final source = addInput('source', Logic(width: 24), width: 24); + final tie = Logic(width: 8, name: 'tie', naming: tieNaming); + final bus = Logic(width: 32, name: 'bus', naming: busNaming); + + tie <= Const(0, width: 8); + bus + ..assignSubset(source.elements) + ..assignSubset(tie.elements, start: 24); + + final consumer = WholeBusChild(bus, n: 32); + addOutput('y', width: 32) <= consumer.output('mirror'); + } +} + +/// Produces the live range for [TiedSiblingRangeToAssignSubsetAssignment]. +class TiedRangeProducer extends Module { + TiedRangeProducer() : super(name: 'tied_range_producer') { + final seed = addInput('seed', Logic(width: 24), width: 24); + addOutput('result', width: 24) <= seed; + } +} + +/// Builds a packed bus from a sibling output and a constant-backed upper +/// range, then passes the whole bus to another sibling input. +class TiedSiblingRangeToAssignSubsetAssignment extends Module { + TiedSiblingRangeToAssignSubsetAssignment() + : super(name: 'tied_sibling_range_to_assign_subset_assignment') { + final seed = addInput('seed', Logic(width: 24), width: 24); + final tie = Logic( + width: 8, + name: 'tie', + naming: Naming.mergeable, + ); + final bus = Logic( + width: 32, + name: 'bus', + naming: Naming.mergeable, + ); + + final producer = TiedRangeProducer(); + producer.inputSource('seed') <= seed; + + tie <= Const(0, width: 8); + bus + ..assignSubset(producer.output('result').elements) + ..assignSubset(tie.elements, start: 24); + + final consumer = WholeBusChild(bus, n: 32); + addOutput('y', width: 32) <= consumer.output('mirror'); + } +} + +/// Late-drives a child input source from a sibling output and a constant-backed +/// upper range. +class TiedSiblingRangeToLateInputSource extends Module { + TiedSiblingRangeToLateInputSource() + : super(name: 'tied_sibling_range_to_late_input_source') { + final seed = addInput('seed', Logic(width: 24), width: 24); + final tie = Const(0, width: 8).named( + 'tie', + naming: Naming.mergeable, + ); + final consumer = WholeBusChild(Logic(width: 32), n: 32); + final producer = TiedRangeProducer(); + + producer.inputSource('seed') <= seed; + consumer.inputSource('data') + ..assignSubset(producer.output('result').elements) + ..assignSubset(tie.elements, start: 24); + + addOutput('y', width: 32) <= consumer.output('mirror'); + } +} + +/// Produces independently mapped scalar outputs for +/// [ScalarSiblingOutputsWithNamedTieTop]. +class ScalarRangeProducer extends Module { + ScalarRangeProducer(this.liveWidth) : super(name: 'scalar_range_producer') { + final seed = addInput('seed', Logic(width: liveWidth), width: liveWidth); + for (var index = 0; index < liveWidth; index++) { + addOutput('bit$index') <= seed[index]; + } + } + + final int liveWidth; +} + +/// Late-drives a child input from scalar sibling outputs followed by a named +/// constant tie-off. +class ScalarSiblingOutputsWithNamedTieTop extends Module { + ScalarSiblingOutputsWithNamedTieTop() + : super(name: 'scalar_sibling_outputs_with_named_tie_top') { + final seed = addInput('seed', Logic(width: liveWidth), width: liveWidth); + final tie = Const(0, width: tieWidth).named( + 'tie', + naming: Naming.mergeable, + ); + final consumer = WholeBusChild(Logic(width: busWidth), n: busWidth); + final producer = ScalarRangeProducer(liveWidth); + + producer.inputSource('seed') <= seed; + for (var index = 0; index < liveWidth; index++) { + consumer.inputSource('data').assignSubset( + [producer.output('bit$index')], + start: index, + ); + } + consumer.inputSource('data').assignSubset(tie.elements, start: liveWidth); + + addOutput('y', width: busWidth) <= consumer.output('mirror'); + } + + static const busWidth = 8; + static const tieWidth = 3; + static const liveWidth = busWidth - tieWidth; +} + +/// Builds a packed bus with a named constant range between live ranges and a +/// scalar sibling output mapped to the top bit. +class InteriorNamedTieWithMappedOutputTop extends Module { + InteriorNamedTieWithMappedOutputTop() + : super(name: 'interior_named_tie_with_mapped_output_top') { + final low = addInput('low', Logic(width: 4), width: 4); + final high = addInput('high', Logic(width: 8), width: 8); + final seed = addInput('seed', Logic()); + final tie = Const(0, width: 3).named( + 'tie', + naming: Naming.mergeable, + ); + final bus = Logic(width: 16, name: 'bus'); + final producer = SiblingBitProducer(); + + producer.inputSource('seed') <= seed; + final aliasedResult = producer.output('result')[0]; + bus + ..assignSubset(low.elements) + ..assignSubset(tie.elements, start: 4) + ..assignSubset(high.elements, start: 7) + ..assignSubset([aliasedResult], start: 15); + + final consumer = WholeBusChild(bus, n: 16); + final secondConsumer = WholeBusChild(bus, n: 16); + addOutput('y', width: 16) <= consumer.output('mirror'); + addOutput('z', width: 16) <= secondConsumer.output('mirror'); + } +} + +/// Mixes repeated nonzero constant ranges with an interior sibling output. +/// The child can be connected through an explicit bus or late-driven directly, +/// and the sibling output can optionally have an additional consumer. +class RepeatedConstantsAndSiblingOutputTop extends Module { + RepeatedConstantsAndSiblingOutputTop({ + required bool lateInput, + required bool fanout, + }) : super(name: 'repeated_constants_and_sibling_output_top') { + final source = addInput('source', Logic(width: 2), width: 2); + final seed = addInput('seed', Logic()); + final tie2 = Const(2, width: 2).named( + 'tie2', + naming: Naming.mergeable, + ); + final tie3 = Const(5, width: 3).named( + 'tie3', + naming: Naming.mergeable, + ); + final producer = SiblingBitProducer(); + producer.inputSource('seed') <= seed; + final result = producer.output('result'); + + late final Logic target; + late final WholeBusChild consumer; + if (lateInput) { + consumer = WholeBusChild(Logic(width: 12), n: 12); + target = consumer.inputSource('data'); + } else { + target = Logic( + width: 12, + name: 'bus', + naming: Naming.mergeable, + ); + consumer = WholeBusChild(target, n: 12); + } + + target + ..assignSubset(tie2.elements) + ..assignSubset(source.elements, start: 2) + ..assignSubset([result], start: 4) + ..assignSubset(tie3.elements, start: 5) + ..assignSubset(source.elements, start: 8) + ..assignSubset(tie2.elements, start: 10); + + addOutput('y', width: 12) <= consumer.output('mirror'); + if (fanout) { + addOutput('fanout') <= result; + } + } +} + +/// Mixes unknown and floating constant ranges with live bits in one packed +/// child input. +class InvalidConstantsToAssignSubsetTop extends Module { + InvalidConstantsToAssignSubsetTop() + : super(name: 'invalid_constants_to_assign_subset_top') { + final source = addInput('source', Logic(width: 2), width: 2); + final unknown = Const(LogicValue.filled(2, LogicValue.x)).named( + 'unknown', + naming: Naming.mergeable, + ); + final floating = Const(LogicValue.filled(2, LogicValue.z)).named( + 'floating', + naming: Naming.mergeable, + ); + final bus = Logic( + width: 6, + name: 'bus', + naming: Naming.mergeable, + ) + ..assignSubset(source.elements) + ..assignSubset(unknown.elements, start: 2) + ..assignSubset(floating.elements, start: 4); + + final consumer = WholeBusChild(bus, n: 6); + addOutput('y', width: 6) <= consumer.output('mirror'); + } +} + +/// Assigns contiguous direct and temporary-sliced bus ranges from an internal +/// source into a flat bus through [Logic.assignSubset]. +class InternalBusRunsToAssignSubsetRangeAssignment extends Module { + InternalBusRunsToAssignSubsetRangeAssignment({bool computedSource = false}) + : super(name: 'internal_bus_runs_to_assign_subset_range_assignment') { + final src = addInput('src', Logic(width: 32), width: 32); + final srcStage = + Logic(width: 32, name: 'srcStage', naming: Naming.mergeable); + final srcLow = Logic(width: 8, name: 'srcLow', naming: Naming.mergeable); + final srcHigh = Logic(width: 16, name: 'srcHigh', naming: Naming.mergeable); + final dst = Logic(width: 64, name: 'dst'); + + srcStage <= (computedSource ? ~src : src); + for (var index = 0; index < 8; index++) { + dst.assignSubset([srcStage[index]], start: index + 13); + } + srcLow <= srcStage.getRange(8, 16); + for (var index = 0; index < 8; index++) { + dst.assignSubset([srcLow[index]], start: index + 21); + } + srcHigh <= srcStage.getRange(16, 32); + for (var index = 0; index < 16; index++) { + dst.assignSubset([srcHigh[index]], start: index + 29); + } + + addOutput('y', width: 64) <= dst; + } +} + +/// Partially assigns a contiguous run from a temporary slice while also using +/// one bit of that slice elsewhere. The run should collapse, but the slice +/// helper must remain live for the extra consumer. +class PartialSliceWithExtraConsumer extends Module { + PartialSliceWithExtraConsumer() + : super(name: 'partial_slice_with_extra_consumer') { + final src = addInput('src', Logic(width: 16), width: 16); + final enable = addInput('enable', Logic()); + final slice = Logic(width: 8, name: 'slice', naming: Naming.mergeable); + final dst = Logic(width: 12, name: 'dst'); + + slice <= src.getRange(4, 12); + for (var index = 2; index <= 5; index++) { + dst.assignSubset([slice[index]], start: index + 3); + } + + addOutput('y', width: 12) <= dst; + addOutput('z') <= enable & ~slice[3]; + } +} + +/// Assigns a temporary flat bus slice into wide array elements. +class WideTemporarySliceToArrayWords extends Module { + WideTemporarySliceToArrayWords({bool extraConsumers = false}) + : super(name: 'wide_temporary_slice_to_array_words') { + final src = addInput('src', Logic(width: 128), width: 128); + final srcSlice = + Logic(width: 64, name: 'src_slice', naming: Naming.mergeable); + final dst = addOutputArray('y', dimensions: [4], elementWidth: 16); + final inverted = []; + + srcSlice <= src.getRange(32, 96); + final words = [ + srcSlice.getRange(0, 16), + srcSlice.getRange(16, 32), + srcSlice.getRange(32, 48), + srcSlice.getRange(48, 64), + ]; + dst.elements[1] <= words[1]; + dst.elements[0] <= words[0]; + dst.elements[2] <= words[2]; + dst.elements[3] <= words[3]; + + if (extraConsumers) { + for (final word in words) { + inverted.add(InverterMod(word, width: 16).o); + } + addOutput('z', width: 64) <= inverted.rswizzle(); + } + } +} + +/// Uses a manually-created array with a subset-like name. +class ManualSubsetNamedArrayRangeAssignment extends Module { + ManualSubsetNamedArrayRangeAssignment() + : super(name: 'manual_subset_named_array_range_assignment') { + final src = addInputArray('src', LogicArray([6], 1), dimensions: [6]); + final intermediate = + LogicArray([6], 1, name: 'manual_subset', naming: Naming.unnamed); + + for (var index = 2; index <= 4; index++) { + intermediate.elements[index] <= src.elements[index]; + } + + addOutput('y', width: 6) <= intermediate.elements.rswizzle(); + } +} + +/// Partially assigns a packed inner dimension of a two-dimensional array. +class PartialInnerArrayRangeAssignment extends Module { + PartialInnerArrayRangeAssignment({int numUnpackedDimensions = 0}) + : super(name: 'partial_inner_array_range_assignment') { + final src = addInputArray( + 'src', + LogicArray([2, 4], 1, numUnpackedDimensions: numUnpackedDimensions), + dimensions: [2, 4], + numUnpackedDimensions: numUnpackedDimensions, + ); + final dst = LogicArray([2, 4], 1, + name: 'dst', numUnpackedDimensions: numUnpackedDimensions); + + final srcRow = src.elements[1] as LogicArray; + final dstRow = dst.elements[1] as LogicArray; + for (var index = 1; index <= 3; index++) { + dstRow.elements[index] <= srcRow.elements[index]; + } + + addOutput('y', width: 8) <= dst.leafElements.rswizzle(); + } +} + +/// Partially assigns an unpacked one-dimensional array, which must not be +/// collapsed into a packed slice. +class PartialUnpackedArrayRangeAssignment extends Module { + PartialUnpackedArrayRangeAssignment() + : super(name: 'partial_unpacked_array_range_assignment') { + final src = addInputArray( + 'src', + LogicArray([6], 1, numUnpackedDimensions: 1), + dimensions: [6], + numUnpackedDimensions: 1, + ); + final dst = LogicArray([6], 1, name: 'dst', numUnpackedDimensions: 1); + + for (var index = 2; index <= 4; index++) { + dst.elements[index] <= src.elements[index]; + } + + addOutput('y', width: 6) <= dst.leafElements.rswizzle(); + } +} + +/// Partially assigns multi-bit array elements, which must not be collapsed by +/// the one-bit range assignment optimization. +class PartialWideArrayRangeAssignment extends Module { + PartialWideArrayRangeAssignment() + : super(name: 'partial_wide_array_range_assignment') { + final src = addInputArray( + 'src', + LogicArray([4], 2), + dimensions: [4], + elementWidth: 2, + ); + final dst = LogicArray([4], 2, name: 'dst'); + + for (var index = 1; index <= 2; index++) { + dst.elements[index] <= src.elements[index]; + } + + addOutput('y', width: 8) <= dst.leafElements.rswizzle(); + } +} + +/// Partially connects net array elements; range collapse must leave these for +/// the net connection flow instead of emitting procedural assignments. +class PartialNetArrayRangeAssignment extends Module { + PartialNetArrayRangeAssignment() + : super(name: 'partial_net_array_range_assignment') { + final src = addInOutArray( + 'src', + LogicArray.net([6], 1), + dimensions: [6], + ); + final mirror = addInOut('mirror', LogicNet(width: 6), width: 6); + final dst = LogicArray.net([6], 1, name: 'dst'); + + for (var index = 2; index <= 4; index++) { + dst.elements[index] <= src.elements[index]; + } + + mirror <= dst.leafElements.rswizzle(); + } +} + +/// Partially connects a flat net bus through bit selections; this is outside +/// array range collapse and should stay in the net connection flow. +class PartialLogicNetRangeAssignment extends Module { + PartialLogicNetRangeAssignment() + : super(name: 'partial_logic_net_range_assignment') { + final src = addInOut('src', LogicNet(width: 6), width: 6); + final mirror = addInOut('mirror', LogicNet(width: 6), width: 6); + final dst = LogicNet(width: 6, name: 'dst'); + + for (var index = 2; index <= 4; index++) { + dst.slice(index, index) <= src.slice(index, index); + } + + mirror <= dst; + } +} + /// Inverts a bus of the given `width`. class InverterMod extends Module { Logic get o => output('o'); @@ -260,6 +935,29 @@ class ArrayPortInvChild extends Module { } } +/// Observes the only element of a packed array input. +class SingleElementArrayConsumer extends Module { + SingleElementArrayConsumer() : super(name: 'single_element_array_consumer') { + final data = addInputArray( + 'data', + LogicArray([1], 8), + dimensions: [1], + elementWidth: 8, + ); + addOutput('observed', width: 8) <= data.elements.single; + } +} + +/// Late-drives a one-element packed array child input with a flat constant. +class ConstantToSingleElementArrayInputTop extends Module { + ConstantToSingleElementArrayInputTop({int value = 0}) + : super(name: 'constant_to_single_element_array_input_top') { + final consumer = SingleElementArrayConsumer(); + consumer.inputSource('data') <= Const(value, width: 8); + addOutput('y', width: 8) <= consumer.output('observed'); + } +} + /// Parent feeding `n` individual signals (each `elementWidth` wide) into a /// single child array port, element-by-element through a mergeable intermediate /// array. `perm` optionally reorders which signal drives which element. @@ -311,6 +1009,34 @@ class MergedSourcesToArrayPort extends Module { } } +/// Parent feeding ranged slices from one source bus into a single child array +/// port through a mergeable intermediate array. +class RangeSourcesToArrayPort extends Module { + Logic get y => output('y'); + RangeSourcesToArrayPort() { + const n = 4; + const elementWidth = 16; + final src = addInput('src', Logic(width: n * elementWidth), + width: n * elementWidth); + final srcSlice = Logic( + width: n * elementWidth, + name: 'srcSlice', + naming: Naming.mergeable, + ); + final arr = + LogicArray([n], elementWidth, name: 'arr', naming: Naming.mergeable); + final child = ArrayPortInvChild(arr, elementWidth: elementWidth); + + srcSlice <= src; + for (var i = 0; i < n; i++) { + arr.elements[i] <= + srcSlice.getRange(i * elementWidth, (i + 1) * elementWidth); + } + + addOutput('y', width: n * elementWidth) <= child.y; + } +} + /// Child with a single inout net array port bidirectionally mirrored to `b`. class ArrayPortNetChild extends Module { ArrayPortNetChild(LogicArray a, LogicNet b, {int n = 4}) @@ -483,6 +1209,53 @@ class WholeNetBusToPort extends Module { } } +/// Like [WholeNetBusToPort], but one bus slice also feeds an inline gate +/// expression. Collapsing the whole bus must not leave that expression reading +/// an undriven subset helper. +class WholeNetBusToPortWithInlineSubsetConsumer extends Module { + WholeNetBusToPortWithInlineSubsetConsumer( + List nets, LogicNet mirror) + : super(name: 'whole_net_bus_to_port_with_inline_subset_consumer') { + final n = nets.length; + final netPorts = [ + for (var i = 0; i < n; i++) addInOut('net$i', nets[i]), + ]; + final enable = addInput('enable', Logic()); + mirror = addInOut('mirror', mirror, width: n); + final bus = LogicNet(width: n, name: 'bus'); + for (var i = 0; i < n; i++) { + bus.slice(i, i) <= netPorts[i]; + } + + addOutput('z') <= enable & ~bus.slice(0, 0); + WholeNetBusChild(bus, mirror, n: n); + } +} + +/// Reads every bit of a net bus through subset helpers, while the whole bus is +/// also consumed by a child. These read-only helpers must not be mistaken for +/// bit definers and removed out from under the inline expression. +class WholeNetBusToPortWithReadOnlyInlineSubsetConsumer extends Module { + WholeNetBusToPortWithReadOnlyInlineSubsetConsumer(LogicNet mirror, + {int n = 4}) + : super( + name: + 'whole_net_bus_to_port_with_read_only_inline_subset_consumer') { + final enable = addInput('enable', Logic()); + mirror = addInOut('mirror', mirror, width: n); + final bus = LogicNet(width: n, name: 'bus'); + final guarded = []; + + for (var i = 0; i < n; i++) { + final selected = bus.slice(i, i); + guarded.add(enable & ~selected); + } + + addOutput('z', width: n) <= guarded.rswizzle(); + WholeNetBusChild(bus, mirror, n: n); + } +} + /// Reproduces the current naming-order issue where temporary [BusSubset] /// instances that will be collapsed still claim basenames before surviving /// signals can use them. @@ -540,6 +1313,31 @@ class BitwiseNetBusToArrayPort extends Module { } } +/// Like [BitwiseNetBusToArrayPort], but the same subset helper that ties one +/// net into the bus also feeds an inline gate expression. +class BitwiseNetBusToArrayPortWithInlineSubsetConsumer extends Module { + BitwiseNetBusToArrayPortWithInlineSubsetConsumer( + List nets, LogicNet mirror) + : super( + name: 'bitwise_net_bus_to_array_port_with_inline_subset_consumer') { + final n = nets.length; + final netPorts = [ + for (var i = 0; i < n; i++) addInOut('net$i', nets[i]), + ]; + final enable = addInput('enable', Logic()); + mirror = addInOut('mirror', mirror, width: n); + final bus = LogicNet(width: n, name: 'bus'); + for (var i = 0; i < n; i++) { + final selected = bus.slice(i, i); + selected <= netPorts[i]; + if (i == 0) { + addOutput('z') <= enable & ~selected; + } + } + ArrayNetBusChild(bus, mirror, n: n); + } +} + /// A flat net bus passed as a whole to a child port, but also read by a second /// consumer (another child). The extra whole use must prevent the bus from /// collapsing. @@ -755,9 +1553,14 @@ class CollapseConfig { bool get fullyCollapses => collapsibleBus && !partial && !multiUse && !toArray; - /// The `*_subset` pass-through arrays should be forwarded away whenever the - /// whole connection can collapse (independent of bus naming). - bool get noSubset => !partial && !multiUse; + /// Whether generated `*_subset` pass-through arrays can be forwarded away. + /// + /// Driver-direction forwarding traces through and removes the intermediate + /// bus, so a preserved bus also preserves those subset arrays. + bool get noSubset => + !partial && + !multiUse && + (mechanism != TieMechanism.subsetDriver || collapsibleBus); String get description => [ if (isNet) 'net' else 'logic', @@ -863,24 +1666,529 @@ class LogicInvChild extends Module { } } -/// Non-net (regular [Logic]) driver-direction `assignSubset`: each external bit -/// drives one bit of `sig` via `assignSubset`, and `sig` feeds a child input. -/// The intermediate `*_subset` array must be forwarded straight into the child -/// connection with no surviving `assign`. -class AssignSubsetLogicDriver extends Module { - AssignSubsetLogicDriver(List bits, {int n = 4}) - : super(name: 'assign_subset_logic_driver') { - final ins = [for (var i = 0; i < n; i++) addInput('b$i', bits[i])]; - final sig = Logic(width: n, name: 'sig', naming: Naming.mergeable); - final child = LogicInvChild(sig, n: n); - addOutput('y', width: n) <= child.output('o'); - for (var i = 0; i < n; i++) { - sig.assignSubset([ins[i]], start: i); - } +/// A child whose input source can be driven after construction. +class LateSubsetInputChild extends Module { + LateSubsetInputChild({super.name = 'late_subset_input_child'}) { + addInput('data', Logic(width: 8), width: 8); + addOutput('out') <= input('data')[0]; } } -/// Partial `assignSubset`: only the low half of the child bus is driven; the +/// Drives a child input source late using [Logic.assignSubset]. +class LateSubsetInputTop extends Module { + LateSubsetInputTop() : super(name: 'late_subset_input_top') { + final source = addInput('source', Logic(width: 8), width: 8); + final child = LateSubsetInputChild(); + + child.inputSource('data').assignSubset(source.elements); + + addOutput('y') <= child.output('out'); + } +} + +/// Drives a child input source late from a slice of a wider source. +class LateSlicedSubsetInputTop extends Module { + LateSlicedSubsetInputTop() : super(name: 'late_sliced_subset_input_top') { + final source = addInput('source', Logic(width: 16), width: 16); + final child = LateSubsetInputChild(); + + child.inputSource('data').assignSubset([ + for (var index = 0; index < 8; index++) source[index + 4], + ]); + + addOutput('y') <= child.output('out'); + } +} + +/// A child that forwards its input to an output for sibling connection tests. +class SiblingSubsetProducer extends Module { + SiblingSubsetProducer({super.name = 'sibling_subset_producer'}) { + final seed = addInput('seed', Logic(width: 4), width: 4); + addOutput('result', width: 4) <= seed; + } +} + +/// A child that observes a bit inside a late-driven input source. +class SiblingSubsetConsumer extends Module { + SiblingSubsetConsumer({super.name = 'sibling_subset_consumer'}) { + addInput('data', Logic(width: 8), width: 8); + addOutput('observed') <= input('data')[2]; + } +} + +/// Width-matched sibling consumer for full-width subset mapping tests. +class SiblingFullSubsetConsumer extends Module { + SiblingFullSubsetConsumer({super.name = 'sibling_full_subset_consumer'}) { + addInput('data', Logic(width: 4), width: 4); + addOutput('observed') <= input('data')[0]; + } +} + +/// Drives one sibling's input source from another sibling's output subset. +class SiblingOutputToInputSubsetTop extends Module { + SiblingOutputToInputSubsetTop() + : super(name: 'sibling_output_to_input_subset_top') { + final source = addInput('source', Logic(width: 4), width: 4); + final producer = SiblingSubsetProducer(); + producer.inputSource('seed') <= source; + + final consumer = SiblingSubsetConsumer(); + consumer.inputSource('data').assignSubset( + producer.output('result').elements, + start: 2, + ); + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Drives one sibling's full input source from another sibling's full output. +class SiblingFullOutputToInputSubsetTop extends Module { + SiblingFullOutputToInputSubsetTop() + : super(name: 'sibling_full_output_to_input_subset_top') { + final source = addInput('source', Logic(width: 4), width: 4); + final producer = SiblingSubsetProducer(); + producer.inputSource('seed') <= source; + + final consumer = SiblingFullSubsetConsumer(); + consumer.inputSource('data').assignSubset( + producer.output('result').elements, + ); + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Produces one bit used by [SiblingOutputWithRangeAssignmentsTop]. +class SiblingBitProducer extends Module { + SiblingBitProducer() : super(name: 'sibling_bit_producer') { + final seed = addInput('seed', Logic()); + addOutput('result') <= seed; + } +} + +/// Observes the upper bit of a whole input bus. +class SiblingUpperBitConsumer extends Module { + SiblingUpperBitConsumer() : super(name: 'sibling_upper_bit_consumer') { + final data = addInput('data', Logic(width: 8), width: 8); + addOutput('observed') <= data[7]; + } +} + +/// Builds a bus from a contiguous input range and one sibling output, then +/// passes the whole bus to another sibling input. +class SiblingOutputWithRangeAssignmentsTop extends Module { + SiblingOutputWithRangeAssignmentsTop() + : super(name: 'sibling_output_with_range_assignments_top') { + final source = addInput('source', Logic(width: 7), width: 7); + final seed = addInput('seed', Logic()); + final bus = Logic(width: 8, name: 'bus'); + + final producer = SiblingBitProducer(); + producer.inputSource('seed') <= seed; + + bus + ..assignSubset(source.elements) + ..assignSubset([producer.output('result')], start: 7); + + final consumer = SiblingUpperBitConsumer(); + consumer.inputSource('data') <= bus; + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Mirrors a whole input bus for mixed-source connection tests. +class SiblingBusConsumer extends Module { + SiblingBusConsumer() : super(name: 'sibling_bus_consumer') { + final data = addInput('data', Logic(width: 8), width: 8); + addOutput('observed', width: 8) <= data; + } +} + +/// Inserts one sibling output at [outputIndex], with input ranges on either +/// side where space permits. +class IndexedSiblingOutputWithRangeAssignmentsTop extends Module { + IndexedSiblingOutputWithRangeAssignmentsTop(this.outputIndex) + : assert( + outputIndex >= 0 && outputIndex < 8, + 'Output index must fit within the eight-bit bus.', + ), + super(name: 'indexed_sibling_output_with_range_assignments_top') { + final source = addInput('source', Logic(width: 7), width: 7); + final seed = addInput('seed', Logic()); + final bus = Logic(width: 8, name: 'bus'); + + final producer = SiblingBitProducer(); + producer.inputSource('seed') <= seed; + + if (outputIndex > 0) { + bus.assignSubset(source.elements.sublist(0, outputIndex)); + } + bus.assignSubset([producer.output('result')], start: outputIndex); + if (outputIndex < 7) { + bus.assignSubset( + source.elements.sublist(outputIndex), + start: outputIndex + 1, + ); + } + + final consumer = SiblingBusConsumer(); + consumer.inputSource('data') <= bus; + addOutput('y', width: 8) <= consumer.output('observed'); + } + + final int outputIndex; +} + +/// Inserts two independent sibling outputs among packed input ranges. +class MultipleSiblingOutputsWithRangeAssignmentsTop extends Module { + MultipleSiblingOutputsWithRangeAssignmentsTop() + : super(name: 'multiple_sibling_outputs_with_range_assignments_top') { + final sourceLow = addInput('sourceLow', Logic(width: 2), width: 2); + final sourceHigh = addInput('sourceHigh', Logic(width: 4), width: 4); + final seed0 = addInput('seed0', Logic()); + final seed1 = addInput('seed1', Logic()); + final bus = Logic( + width: 8, + name: 'bus', + naming: Naming.mergeable, + ); + + final producer0 = SiblingBitProducer(); + final producer1 = SiblingBitProducer(); + producer0.inputSource('seed') <= seed0; + producer1.inputSource('seed') <= seed1; + + bus + ..assignSubset(sourceLow.elements) + ..assignSubset([producer0.output('result')], start: 2) + ..assignSubset(sourceHigh.elements, start: 3) + ..assignSubset([producer1.output('result')], start: 7); + + final consumer = SiblingBusConsumer(); + consumer.inputSource('data') <= bus; + addOutput('y', width: 8) <= consumer.output('observed'); + } +} + +/// Uses one sibling output both inside a packed bus and as a separate output. +class FanoutSiblingOutputWithRangeAssignmentsTop extends Module { + FanoutSiblingOutputWithRangeAssignmentsTop() + : super(name: 'fanout_sibling_output_with_range_assignments_top') { + final source = addInput('source', Logic(width: 7), width: 7); + final seed = addInput('seed', Logic()); + final bus = Logic( + width: 8, + name: 'bus', + naming: Naming.mergeable, + ); + + final producer = SiblingBitProducer(); + producer.inputSource('seed') <= seed; + final result = producer.output('result'); + + bus + ..assignSubset(source.elements) + ..assignSubset([result], start: 7); + + final consumer = SiblingBusConsumer(); + consumer.inputSource('data') <= bus; + addOutput('y', width: 8) <= consumer.output('observed'); + addOutput('fanout') <= result; + } +} + +/// Inserts a four-bit sibling output between two packed input ranges. +class WideSiblingOutputWithRangeAssignmentsTop extends Module { + WideSiblingOutputWithRangeAssignmentsTop() + : super(name: 'wide_sibling_output_with_range_assignments_top') { + final source = addInput('source', Logic(width: 4), width: 4); + final seed = addInput('seed', Logic(width: 4), width: 4); + final bus = Logic(width: 8, name: 'bus'); + + final producer = SiblingSubsetProducer(); + producer.inputSource('seed') <= seed; + + bus + ..assignSubset(source.elements.sublist(0, 2)) + ..assignSubset(producer.output('result').elements, start: 2) + ..assignSubset(source.elements.sublist(2), start: 6); + + final consumer = SiblingBusConsumer(); + consumer.inputSource('data') <= bus; + addOutput('y', width: 8) <= consumer.output('observed'); + } +} + +/// Places a wide sibling output between repeated nonzero constant ranges and +/// also exposes that output separately. +class WideSiblingOutputWithConstantsAndFanoutTop extends Module { + WideSiblingOutputWithConstantsAndFanoutTop() + : super(name: 'wide_sibling_output_with_constants_and_fanout_top') { + final seed = addInput('seed', Logic(width: 4), width: 4); + final tie = Const(2, width: 2).named( + 'tie', + naming: Naming.mergeable, + ); + final bus = Logic( + width: 8, + name: 'bus', + naming: Naming.mergeable, + ); + final producer = SiblingSubsetProducer(); + producer.inputSource('seed') <= seed; + final result = producer.output('result'); + + bus + ..assignSubset(tie.elements) + ..assignSubset(result.elements, start: 2) + ..assignSubset(tie.elements, start: 6); + + final consumer = SiblingBusConsumer(); + consumer.inputSource('data') <= bus; + addOutput('y', width: 8) <= consumer.output('observed'); + addOutput('fanout', width: 4) <= result; + } +} + +/// Array-output sibling variant of [SiblingSubsetProducer]. +class SiblingArraySubsetProducer extends Module { + SiblingArraySubsetProducer({super.name = 'sibling_array_subset_producer'}) { + final seed = addInput('seed', Logic(width: 4), width: 4); + final result = addOutputArray('result', dimensions: [4]); + + for (var index = 0; index < 4; index++) { + result.elements[index] <= seed[index]; + } + } +} + +/// Array-input sibling variant of [SiblingSubsetConsumer]. +class SiblingArraySubsetConsumer extends Module { + SiblingArraySubsetConsumer({super.name = 'sibling_array_subset_consumer'}) { + final data = addInputArray('data', LogicArray([8], 1), dimensions: [8]); + addOutput('observed') <= data.elements[2]; + } +} + +/// Drives one sibling's input array source from another sibling's output array. +class SiblingArrayOutputToInputSubsetTop extends Module { + SiblingArrayOutputToInputSubsetTop() + : super(name: 'sibling_array_output_to_input_subset_top') { + final source = addInput('source', Logic(width: 4), width: 4); + final producer = SiblingArraySubsetProducer(); + producer.inputSource('seed') <= source; + + final consumer = SiblingArraySubsetConsumer(); + (consumer.inputSource('data') as LogicArray).assignSubset( + (producer.output('result') as LogicArray).elements, + start: 2, + ); + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Small structure used for sibling subset mapping regressions. +class SiblingSubsetStruct extends LogicStructure { + final Logic low; + final Logic high; + + factory SiblingSubsetStruct({String name = 'sibling_subset_struct'}) => + SiblingSubsetStruct._( + Logic(name: 'low'), + Logic(name: 'high'), + name: name, + ); + + SiblingSubsetStruct._(this.low, this.high, {super.name}) : super([low, high]); + + @override + SiblingSubsetStruct clone({String? name}) => + SiblingSubsetStruct(name: name ?? this.name); +} + +/// Structure-output sibling variant of [SiblingSubsetProducer]. +class SiblingStructSubsetProducer extends Module { + SiblingStructSubsetProducer({super.name = 'sibling_struct_subset_producer'}) { + final seed = addInput('seed', Logic(width: 2), width: 2); + final result = addTypedOutput('result', SiblingSubsetStruct.new); + + result.low <= seed[0]; + result.high <= seed[1]; + } +} + +/// Structure-input sibling variant of [SiblingSubsetConsumer]. +class SiblingStructSubsetConsumer extends Module { + SiblingStructSubsetConsumer({super.name = 'sibling_struct_subset_consumer'}) { + final data = addTypedInput('data', SiblingSubsetStruct()); + addOutput('observed') <= data.high; + } +} + +/// Drives one sibling's input structure source from another sibling's output +/// structure. +class SiblingStructOutputToInputSubsetTop extends Module { + SiblingStructOutputToInputSubsetTop() + : super(name: 'sibling_struct_output_to_input_subset_top') { + final source = addInput('source', Logic(width: 2), width: 2); + final producer = SiblingStructSubsetProducer(); + producer.inputSource('seed') <= source; + + final consumer = SiblingStructSubsetConsumer(); + (consumer.inputSource('data') as SiblingSubsetStruct).assignSubset( + (producer.output('result') as SiblingSubsetStruct).elements, + ); + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Inout sibling variant that exposes a net bus through a source mapping. +class SiblingInOutSubsetProducer extends Module { + SiblingInOutSubsetProducer({super.name = 'sibling_inout_subset_producer'}) { + addInOut('link', LogicNet(width: 4), width: 4); + } +} + +/// Inout sibling variant that observes a bit from an inout source mapping. +class SiblingInOutSubsetConsumer extends Module { + SiblingInOutSubsetConsumer({super.name = 'sibling_inout_subset_consumer'}) { + final data = addInOut('data', LogicNet(width: 8), width: 8); + addOutput('observed') <= data.slice(2, 2); + } +} + +/// Drives one sibling's inout source subset from another sibling's inout +/// source. +class SiblingInOutToInOutSubsetTop extends Module { + SiblingInOutToInOutSubsetTop() + : super(name: 'sibling_inout_to_inout_subset_top') { + final source = addInOut('source', LogicNet(width: 4), width: 4); + final producer = SiblingInOutSubsetProducer(); + producer.inOutSource('link') <= source; + + final consumer = SiblingInOutSubsetConsumer(); + consumer.inOutSource('data').assignSubset([ + for (var index = 0; index < 4; index++) + producer.inOutSource('link').slice(index, index), + ], start: 2); + + addOutput('y') <= consumer.output('observed'); + } +} + +/// Producer with scalar, array, structure, and inout ports for mixed boundary +/// mapping regressions. +class SiblingBoundaryProductProducer extends Module { + SiblingBoundaryProductProducer( + {super.name = 'sibling_boundary_product_producer'}) { + final seed = addInput('seed', Logic(width: 4), width: 4); + addOutput('wide', width: 4) <= seed; + + final arr = addOutputArray('arr', dimensions: [4]); + for (var index = 0; index < 4; index++) { + arr.elements[index] <= seed[index]; + } + + final pair = addTypedOutput('pair', SiblingSubsetStruct.new); + pair.low <= seed[0]; + pair.high <= seed[1]; + + addInOut('link', LogicNet(width: 4), width: 4); + } +} + +/// Consumer with mixed port shapes that observes one bit from each mapping. +class SiblingBoundaryProductConsumer extends Module { + SiblingBoundaryProductConsumer( + {super.name = 'sibling_boundary_product_consumer'}) { + addInput('wide_from_scalar', Logic(width: 10), width: 10); + final arrayFromScalar = addInputArray( + 'array_from_scalar', + LogicArray([10], 1), + dimensions: [10], + ); + final structFromArray = addTypedInput( + 'struct_from_array', + SiblingSubsetStruct(), + ); + addInput('wide_from_struct', Logic(width: 8), width: 8); + final netFromNet = addInOut('net_from_net', LogicNet(width: 10), width: 10); + + addOutput('scalar_bit') <= input('wide_from_scalar')[5]; + addOutput('array_bit') <= arrayFromScalar.elements[6]; + addOutput('struct_bit') <= structFromArray.high; + addOutput('wide_struct_bit') <= input('wide_from_struct')[4]; + addOutput('net_bit') <= netFromNet.slice(6, 6); + } +} + +/// Mixed sibling boundary fixture that exercises several source/destination +/// shape combinations in one pruning/collapse pass. +class SiblingBoundaryProductTop extends Module { + SiblingBoundaryProductTop() : super(name: 'sibling_boundary_product_top') { + final source = addInput('source', Logic(width: 4), width: 4); + final netSource = addInOut('net_source', LogicNet(width: 4), width: 4); + + final producer = SiblingBoundaryProductProducer(); + producer.inputSource('seed') <= source; + producer.inOutSource('link') <= netSource; + + final consumer = SiblingBoundaryProductConsumer(); + consumer.inputSource('wide_from_scalar').assignSubset( + producer.output('wide').elements, + start: 4, + ); + (consumer.inputSource('array_from_scalar') as LogicArray).assignSubset( + producer.output('wide').elements, + start: 4, + ); + (consumer.inputSource('struct_from_array') as SiblingSubsetStruct) + .assignSubset( + (producer.output('arr') as LogicArray).elements.sublist(1, 3), + ); + consumer.inputSource('wide_from_struct').assignSubset( + (producer.output('pair') as SiblingSubsetStruct).elements, + start: 3, + ); + consumer.inOutSource('net_from_net').assignSubset([ + for (var index = 0; index < 4; index++) + producer.inOutSource('link').slice(index, index), + ], start: 5); + + for (final outputName in [ + 'scalar_bit', + 'array_bit', + 'struct_bit', + 'wide_struct_bit', + 'net_bit', + ]) { + addOutput(outputName) <= consumer.output(outputName); + } + } +} + +/// Non-net (regular [Logic]) driver-direction `assignSubset`: each external bit +/// drives one bit of `sig` via `assignSubset`, and `sig` feeds a child input. +/// The intermediate `*_subset` array must be forwarded straight into the child +/// connection with no surviving `assign`. +class AssignSubsetLogicDriver extends Module { + AssignSubsetLogicDriver(List bits, {int n = 4}) + : super(name: 'assign_subset_logic_driver') { + final ins = [for (var i = 0; i < n; i++) addInput('b$i', bits[i])]; + final sig = Logic(width: n, name: 'sig', naming: Naming.mergeable); + final child = LogicInvChild(sig, n: n); + addOutput('y', width: n) <= child.output('o'); + for (var i = 0; i < n; i++) { + sig.assignSubset([ins[i]], start: i); + } + } +} + +/// Partial `assignSubset`: only the low half of the child bus is driven; the /// high half stays undriven (`z`). Because not every element is a /// pass-through, the intermediate `*_subset` array must be conservatively /// preserved (no collapse) so the undriven high bits remain `z`. @@ -891,58 +2199,1037 @@ class AssignSubsetPartial extends Module { final netPorts = [ for (var i = 0; i < lo; i++) addInOut('net$i', nets[i]), ]; - mirror = addInOut('mirror', mirror, width: n); - final bus = LogicNet(width: n, name: 'bus', naming: Naming.mergeable); - WholeNetBusChild(bus, mirror, n: n); - for (var i = 0; i < lo; i++) { - bus.assignSubset([netPorts[i]], start: i); + mirror = addInOut('mirror', mirror, width: n); + final bus = LogicNet(width: n, name: 'bus', naming: Naming.mergeable); + WholeNetBusChild(bus, mirror, n: n); + for (var i = 0; i < lo; i++) { + bus.assignSubset([netPorts[i]], start: i); + } + } +} + +/// Returns the body of the last (top-level) module declaration in [sv], +/// avoiding false matches inside `endmodule`. +String _topModuleBody(String sv) { + final matches = RegExp(r'(?:^|\n)module ').allMatches(sv).toList(); + return sv.substring(matches.last.start); +} + +LogicValue _expectedPartialArrayRangeValue(int pattern, + {required bool reversed}) => + _expectedSparseValue(6, pattern, (dstIndex) { + if (dstIndex < 2 || dstIndex > 4) { + return null; + } + return reversed ? 6 - dstIndex : dstIndex; + }); + +LogicValue _expectedSparseValue( + int width, + int pattern, + int? Function(int dstIndex) srcIndexFor, +) { + final bits = []; + for (var dstIndex = width - 1; dstIndex >= 0; dstIndex--) { + final srcIndex = srcIndexFor(dstIndex); + bits.add(srcIndex == null ? 'z' : '${(pattern >> srcIndex) & 1}'); + } + return LogicValue.ofString(bits.join()); +} + +LogicValue _expectedWideTemporarySlice(int pattern) => + LogicValue.ofInt(pattern, 96).getRange(32, 96); + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + test('simple 1d collapse', () async { + final mod = SimpleLAPassthrough(LogicArray([4], 1)); + await mod.build(); + final sv = mod.generateSynth(); + + expect(sv, contains('assign laOut = laIn;')); + }); + + test('array collapse for cross-module connection', () async { + final mod = ArrayTopMod(Logic()); + await mod.build(); + final sv = mod.generateSynth(); + + expect(sv, contains(RegExp(r'ArraySubModIn.*\.inp\(inp\)'))); + expect(sv, contains(RegExp(r'ArraySubModOut.*\.arrOut\(inp\)'))); + }); + + test('array nets with intermediate collapse', () async { + final mod = ArrayModuleWithNetIntermediates( + LogicArray([3, 3], 1), LogicArray([3, 3], 1)); + await mod.build(); + + final sv = mod.generateSynth(); + expect(sv, + contains('net_connect #(.WIDTH(9)) net_connect (intermediate, a);')); + expect(sv, + contains('net_connect #(.WIDTH(9)) net_connect_0 (b, intermediate);')); + + final vectors = [ + Vector({'a': 0}, {'b': 0}), + Vector({'a': 123}, {'b': 123}), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('partial array assignments collapse into range assignment', () async { + final mod = PartialArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[4:2] = src[4:2];')); + expect(topBody, isNot(contains('assign dst[2] = src[2];'))); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('chained partial array range assignments collapse through intermediate', + () async { + final mod = ChainedPartialArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[4:2] = src[4:2];')); + expect(topBody, isNot(contains('intermediate'))); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('chained range assignment composes contained subrange offsets', + () async { + final mod = ChainedSubrangeArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[3:2] = src[6:5];')); + expect(topBody, isNot(contains('intermediate'))); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 3 ? dstIndex + 3 : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('three-deep chained range assignments collapse iteratively', () async { + final mod = ThreeDeepChainedPartialArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[4:2] = src[4:2];')); + expect(topBody, isNot(contains('intermediate0'))); + expect(topBody, isNot(contains('intermediate1'))); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('long chained range assignments collapse without global rescans', + () async { + final mod = LongChainedPartialArrayRangeAssignment(); + await mod.build(); + final topBody = _topModuleBody(mod.generateSynth()); + + expect(topBody, contains('assign dst[4:2] = src[4:2];')); + expect(topBody, isNot(contains('intermediate'))); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('multi-use chained range intermediate stays expanded', () async { + final mod = ChainedPartialArrayRangeAssignment(exposeIntermediate: true); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2] = src[4:2];'))); + expect(topBody, contains('assign dst[4:2] = intermediate[4:2];')); + expect(topBody, contains('assign intermediate[4:2] = src[4:2];')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + 'z': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('renameable chained range intermediate stays expanded', () async { + final mod = ChainedPartialArrayRangeAssignment(intermediateNaming: null); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2] = src[4:2];'))); + expect(topBody, contains('assign dst[4:2] = intermediate[4:2];')); + expect(topBody, contains('assign intermediate[4:2] = src[4:2];')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('partial bus-to-array assignments collapse into range assignment', + () async { + final mod = PartialBusToArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[5:2] = src[5:2];')); + expect(topBody, isNot(contains('bussubset'))); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 5 ? dstIndex : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('full array-to-bus assignSubset has no subset intermediate', () async { + final mod = ArrayToBusAssignSubsetRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('_subset'))); + expect(topBody, isNot(contains('assign dst[0]'))); + expect(topBody, contains('assign dst = src[7:0];')); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({'src': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('partial array-to-bus assignSubset collapses into range assignment', + () async { + final mod = ArrayToBusAssignSubsetRangeAssignment(partial: true); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[5:2] = src[5:2];')); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 5 ? dstIndex : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + for (final config in [ + (receiverIsOutput: false, driveLowBits: true, dstName: 'dst'), + (receiverIsOutput: true, driveLowBits: true, dstName: 'y'), + (receiverIsOutput: false, driveLowBits: false, dstName: 'dst'), + (receiverIsOutput: true, driveLowBits: false, dstName: 'y'), + ]) { + test( + 'bus slice temporary feeding assignSubset collapses into ranges ' + '(receiver is ${config.receiverIsOutput ? 'output' : 'internal'}, ' + '${config.driveLowBits ? 'full' : 'partial'} coverage)', () async { + final mod = BusSliceTemporaryToAssignSubsetRangeAssignment( + receiverIsOutput: config.receiverIsOutput, + driveLowBits: config.driveLowBits, + ); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + if (config.driveLowBits) { + expect(topBody, contains('assign ${config.dstName}[3:0] = src[3:0];')); + } else { + expect(topBody, isNot(contains('assign ${config.dstName}[3:0] ='))); + } + expect(topBody, contains('assign ${config.dstName}[7:4] = src[14:11];')); + expect(topBody, isNot(contains('src_slice'))); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x0000, 0x1234, 0x5AA5, 0xFFFF]) + Vector({ + 'src': pattern + }, { + 'y': config.driveLowBits + ? LogicValue.ofInt( + (pattern & 0xF) | (((pattern >> 11) & 0xF) << 4), + 8, + ) + : _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 4 ? dstIndex + 7 : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + } + + test('bus subset helpers with extra consumers are preserved', () async { + final mod = BusSubsetBitsWithExtraConsumers(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[3:0] = src[5:2];')); + expect(topBody, contains(RegExp(r'\.i\([^)]*src'))); + + final vectors = [ + for (final pattern in [0x00, 0x3C, 0xA5, 0xFF]) + Vector({ + 'src': pattern, + }, { + 'y': (pattern >> 2) & 0xF, + 'z': (~((pattern >> 2) & 0xF)) & 0xF, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('partial slice helper with extra consumer is preserved', () async { + final mod = PartialSliceWithExtraConsumer(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[8:5] = src[9:6];')); + expect(topBody, contains('assign slice = src[11:4];')); + expect(topBody, contains('slice[3]')); + expect(topBody, isNot(contains(RegExp(r'assign dst\[[0-9]+\]')))); + + final vectors = [ + for (final pattern in [0x0000, 0x0080, 0x03c0, 0xffff]) + for (final enable in [0, 1]) + Vector({ + 'src': pattern, + 'enable': enable, + }, { + 'y': _expectedSparseValue( + 12, + pattern, + (dstIndex) => + dstIndex >= 5 && dstIndex <= 8 ? dstIndex + 1 : null, + ), + 'z': enable == 1 ? (~((pattern >> 7) & 1)) & 1 : 0, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sparse bus runs feeding assignSubset collapse independently', () async { + final mod = SparseBusRunsToAssignSubsetRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[31:20] = srcA[15:4];')); + expect(topBody, contains('assign dst[55:44] = srcB[11:0];')); + expect(topBody, isNot(contains('_subset'))); + expect(topBody, isNot(contains(RegExp(r'assign dst\[[0-9]+\]')))); + + LogicValue expectedValue(int srcA, int srcB) => _expectedSparseValue( + 64, + srcA, + (dstIndex) => dstIndex >= 20 && dstIndex <= 31 ? dstIndex - 16 : null, + ).withSet( + 44, + LogicValue.ofInt(srcB & 0xfff, 12), + ); + + final vectors = [ + for (final pattern in [0x00000000, 0x12345678, 0x89abcdef]) + Vector({ + 'srcA': pattern, + 'srcB': pattern >> 4, + }, { + 'y': expectedValue(pattern, pattern >> 4), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('constant-backed upper range remains tied off after collapse', () async { + final mod = TiedRangeToAssignSubsetRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data(({')); + expect(topBody, contains("8'h0")); + expect(topBody, contains('source')); + expect(topBody, isNot(contains('logic [31:0] bus;'))); + expect(topBody, isNot(contains('logic [7:0] tie;'))); + expect(topBody, isNot(contains(RegExp(r'\bbus_subset\b')))); + + final vectors = [ + for (final pattern in [0x000000, 0x123456, 0xabcdef, 0xffffff]) + Vector({'source': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + for (final tieNaming in [Naming.renameable, Naming.reserved]) { + test('${tieNaming.name} constant-backed range signal is preserved', + () async { + final mod = TiedRangeToAssignSubsetRangeAssignment( + tieNaming: tieNaming, + ); + await mod.build(); + final topBody = _topModuleBody(mod.generateSynth()); + + expect(topBody, contains('logic [7:0] tie;')); + expect(topBody, contains("assign tie = 8'h0;")); + + final vectors = [ + for (final pattern in [0x000000, 0x123456, 0xffffff]) + Vector({'source': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + } + + test('renameable packed range destination is preserved', () async { + final mod = TiedRangeToAssignSubsetRangeAssignment( + busNaming: Naming.renameable, + ); + await mod.build(); + final topBody = _topModuleBody(mod.generateSynth()); + + expect(topBody, contains('logic [31:0] bus;')); + expect(topBody, contains('.data(bus)')); + + final vectors = [ + for (final pattern in [0x000000, 0x123456, 0xffffff]) + Vector({'source': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('constant-backed range concatenates with sibling output', () async { + final mod = TiedSiblingRangeToAssignSubsetAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data(({')); + expect(topBody, contains("8'h0")); + expect(topBody, isNot(contains('.result()'))); + expect(topBody, isNot(contains('logic [31:0] bus;'))); + expect(topBody, isNot(contains('logic [7:0] tie;'))); + expect(topBody, isNot(contains(RegExp(r'\bbus_subset\b')))); + + final vectors = [ + for (final pattern in [0x000000, 0x123456, 0xabcdef, 0xffffff]) + Vector({'seed': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('constant-backed range concatenates into late child input', () async { + final mod = TiedSiblingRangeToLateInputSource(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data(({')); + expect(topBody, contains("8'h0")); + expect(topBody, isNot(contains('.result()'))); + expect(topBody, isNot(contains('logic [7:0] tie;'))); + expect(topBody, isNot(contains(RegExp(r'\bdata_subset\b')))); + + final vectors = [ + for (final pattern in [0x000000, 0x123456, 0xabcdef, 0xffffff]) + Vector({'seed': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('named constant subsets survive scalar output collapse', () async { + final mod = ScalarSiblingOutputsWithNamedTieTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data(({')); + expect(topBody, contains(RegExp(r"\d+'h0"))); + expect(topBody, isNot(contains('.bit0()'))); + expect(topBody, isNot(contains('.bit4()'))); + expect(topBody, isNot(contains('logic [7:0] data;'))); + expect(topBody, isNot(contains(RegExp(r'\bdata_subset\b')))); + + const liveWidth = ScalarSiblingOutputsWithNamedTieTop.liveWidth; + final maxValue = (BigInt.one << liveWidth) - BigInt.one; + final vectors = [ + for (final pattern in [ + BigInt.zero, + BigInt.one, + BigInt.from(0x5a5a5a) & maxValue, + maxValue, + ]) + Vector({'seed': pattern}, {'y': pattern}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('interior named constant range survives mapped sibling output', + () async { + final mod = InteriorNamedTieWithMappedOutputTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains("assign bus[6:4] = 3'h0;")); + expect(topBody, contains('.result(bus[15])')); + expect(topBody, isNot(contains('logic [2:0] tie;'))); + expect(topBody, isNot(contains(RegExp(r'\bbus_subset\b')))); + + final vectors = [ + for (final low in [0x0, 0x5, 0xf]) + for (final high in [0x00, 0x5a, 0xff]) + for (final seed in [0, 1]) + Vector({ + 'low': low, + 'high': high, + 'seed': seed, + }, { + 'y': low | (high << 7) | (seed << 15), + 'z': low | (high << 7) | (seed << 15), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + for (final lateInput in [false, true]) { + for (final fanout in [false, true]) { + test( + 'repeated constants survive ${lateInput ? 'late input' : 'bus'} ' + 'with sibling output${fanout ? ' fanout' : ''}', () async { + final mod = RepeatedConstantsAndSiblingOutputTop( + lateInput: lateInput, + fanout: fanout, + ); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data(({')); + expect(topBody, contains("2'h2")); + expect(topBody, contains("3'h5")); + expect(topBody, isNot(contains('.result()'))); + expect(topBody, isNot(contains('logic [11:0] bus;'))); + expect(topBody, isNot(contains('logic [1:0] tie2;'))); + expect(topBody, isNot(contains('logic [2:0] tie3;'))); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final source in [0, 1, 2, 3]) + for (final seed in [0, 1]) + Vector({ + 'source': source, + 'seed': seed, + }, { + 'y': 2 | + (source << 2) | + (seed << 4) | + (5 << 5) | + (source << 8) | + (2 << 10), + if (fanout) 'fanout': seed, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); } } -} -/// Returns the body of the last (top-level) module declaration in [sv], -/// avoiding false matches inside `endmodule`. -String _topModuleBody(String sv) { - final matches = RegExp(r'(?:^|\n)module ').allMatches(sv).toList(); - return sv.substring(matches.last.start); -} + test('unknown and floating constant ranges preserve four-state values', + () async { + final mod = InvalidConstantsToAssignSubsetTop(); + await mod.build(); + final topBody = _topModuleBody(mod.generateSynth()); -void main() { - tearDown(() async { - await Simulator.reset(); + expect(topBody, contains("2'bxx")); + expect(topBody, isNot(contains("2'bzz"))); + expect(topBody, contains('logic [5:0] data;')); + expect(topBody, contains('.data(data)')); + expect(topBody, isNot(contains('.data()'))); + + final vectors = [ + for (final source in [0, 1, 2, 3]) + Vector({ + 'source': source, + }, { + 'y': LogicValue.ofString( + 'zzxx${source.toRadixString(2).padLeft(2, '0')}', + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); }); - test('simple 1d collapse', () async { - final mod = SimpleLAPassthrough(LogicArray([4], 1)); + test('internal bus runs feeding assignSubset collapse through slices', + () async { + final mod = InternalBusRunsToAssignSubsetRangeAssignment(); await mod.build(); final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); - expect(sv, contains('assign laOut = laIn;')); + expect(topBody, contains('assign dst[44:13] = src[31:0];')); + expect(topBody, isNot(contains('srcStage'))); + expect(topBody, isNot(contains('srcLow'))); + expect(topBody, isNot(contains('srcHigh'))); + expect(topBody, isNot(contains('_subset'))); + expect(topBody, isNot(contains(RegExp(r'assign dst\[[0-9]+\]')))); + + final vectors = [ + for (final pattern in [0x00000000, 0x12345678, 0x89abcdef]) + Vector({ + 'src': pattern, + }, { + 'y': _expectedSparseValue( + 64, + pattern, + (dstIndex) => + dstIndex >= 13 && dstIndex <= 44 ? dstIndex - 13 : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); }); - test('array collapse for cross-module connection', () async { - final mod = ArrayTopMod(Logic()); + test( + 'computed internal bus runs feeding assignSubset collapse through ' + 'slices', () async { + final mod = InternalBusRunsToAssignSubsetRangeAssignment( + computedSource: true, + ); await mod.build(); final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); - expect(sv, contains(RegExp(r'ArraySubModIn.*\.inp\(inp\)'))); - expect(sv, contains(RegExp(r'ArraySubModOut.*\.arrOut\(inp\)'))); + expect(topBody, contains('assign dst[44:13] = srcStage[31:0];')); + expect(topBody, isNot(contains('srcLow'))); + expect(topBody, isNot(contains('srcHigh'))); + expect(topBody, isNot(contains('_subset'))); + expect(topBody, isNot(contains(RegExp(r'assign dst\[[0-9]+\]')))); + + final vectors = [ + for (final pattern in [0x00000000, 0x12345678, 0x89abcdef]) + Vector({ + 'src': pattern, + }, { + 'y': _expectedSparseValue( + 64, + ~pattern, + (dstIndex) => + dstIndex >= 13 && dstIndex <= 44 ? dstIndex - 13 : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); }); - test('array nets with intermediate collapse', () async { - final mod = ArrayModuleWithNetIntermediates( - LogicArray([3, 3], 1), LogicArray([3, 3], 1)); + test('wide temporary bus slice feeding array words eliminates temporary', + () async { + final mod = WideTemporarySliceToArrayWords(); await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + expect(topBody, contains('assign y[0][15:0] = src[47:32];')); + expect(topBody, contains('assign y[1][15:0] = src[63:48];')); + expect(topBody, contains('assign y[2][15:0] = src[79:64];')); + expect(topBody, contains('assign y[3][15:0] = src[95:80];')); + expect(topBody, isNot(contains('src_slice'))); + + final vectors = [ + for (final pattern in [ + 0, + 0x123456789abc, + 0xffffffffffff, + ]) + Vector({ + 'src': pattern, + }, { + 'y': _expectedWideTemporarySlice(pattern), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('wide temporary slice helpers with extra consumers are preserved', + () async { + final mod = WideTemporarySliceToArrayWords(extraConsumers: true); + await mod.build(); final sv = mod.generateSynth(); - expect(sv, - contains('net_connect #(.WIDTH(9)) net_connect (intermediate, a);')); - expect(sv, - contains('net_connect #(.WIDTH(9)) net_connect_0 (b, intermediate);')); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign y[0][15:0] = src[47:32];')); + expect(topBody, contains('assign y[3][15:0] = src[95:80];')); + expect(topBody, contains(RegExp(r'\.i\([^)]*src_slice'))); final vectors = [ - Vector({'a': 0}, {'b': 0}), - Vector({'a': 123}, {'b': 123}), + for (final pattern in [ + 0, + 0x123456789abc, + 0xffffffffffff, + ]) + Vector({ + 'src': pattern, + }, { + 'y': _expectedWideTemporarySlice(pattern), + 'z': ~_expectedWideTemporarySlice(pattern), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('subset-like manual array name does not trigger generated subset fold', + () async { + final mod = ManualSubsetNamedArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('manual_subset')); + expect(topBody, contains('assign manual_subset[4:2] = src[4:2];')); + expect(topBody, contains('assign y = manual_subset[5:0];')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('reordered bus-to-array assignments stay expanded', () async { + final mod = PartialBusToArrayRangeAssignment(reversed: true); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[5:2] = src[5:2];'))); + expect(topBody, contains('assign dst[2] = src[5];')); + expect(topBody, contains('assign dst[3] = src[4];')); + expect(topBody, contains('assign dst[4] = src[3];')); + expect(topBody, contains('assign dst[5] = src[2];')); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 5 ? 7 - dstIndex : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('bus-to-unpacked-array assignments stay expanded', () async { + final mod = PartialBusToArrayRangeAssignment(numUnpackedDimensions: 1); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[5:2] = src[5:2];'))); + expect(topBody, contains('assign dst[2] = src[2];')); + expect(topBody, contains('assign dst[3] = src[3];')); + expect(topBody, contains('assign dst[4] = src[4];')); + expect(topBody, contains('assign dst[5] = src[5];')); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 5 ? dstIndex : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + }); + + test('non-contiguous partial array assignments stay expanded', () async { + final mod = PartialArrayRangeAssignment(reversed: true); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2]'))); + expect(topBody, contains('assign dst[2] = src[4];')); + expect(topBody, contains('assign dst[3] = src[3];')); + expect(topBody, contains('assign dst[4] = src[2];')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: true), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('packed multidimensional partial assignments collapse inner range', + () async { + final mod = PartialInnerArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[1][3:1] = src[1][3:1];')); + expect(topBody, isNot(contains('assign dst[1][1] = src[1][1];'))); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue(8, pattern, (dstIndex) { + final outerIndex = dstIndex ~/ 4; + final innerIndex = dstIndex % 4; + return outerIndex == 1 && innerIndex >= 1 ? dstIndex : null; + }), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('unpacked outer dimension still collapses inner packed range', () async { + final mod = PartialInnerArrayRangeAssignment(numUnpackedDimensions: 1); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('assign dst[1][3:1] = src[1][3:1];')); + expect(topBody, isNot(contains(RegExp(r'assign dst\[[0-9]+:[0-9]+\]')))); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue(8, pattern, (dstIndex) { + final outerIndex = dstIndex ~/ 4; + final innerIndex = dstIndex % 4; + return outerIndex == 1 && innerIndex >= 1 ? dstIndex : null; + }), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + }); + + test('unpacked one-dimensional partial assignments stay expanded', () async { + final mod = PartialUnpackedArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2] = src[4:2];'))); + expect(topBody, contains('assign dst[2] = src[2];')); + expect(topBody, contains('assign dst[3] = src[3];')); + expect(topBody, contains('assign dst[4] = src[4];')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'y': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + }); + + test('wide element partial array assignments stay expanded', () async { + final mod = PartialWideArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[2:1] = src[2:1];'))); + expect(topBody, contains('assign dst[1] = src[1];')); + expect(topBody, contains('assign dst[2] = src[2];')); + + final vectors = [ + for (final pattern in [0x00, 0x5A, 0xA5, 0xFF]) + Vector({ + 'src': pattern + }, { + 'y': _expectedSparseValue( + 8, + pattern, + (dstIndex) => dstIndex >= 2 && dstIndex <= 5 ? dstIndex : null, + ), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('net array partial assignments stay in net connection flow', () async { + final mod = PartialNetArrayRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2] = src[4:2];'))); + expect(topBody, contains('net_connect')); + expect(topBody, contains(RegExp(r'net_connect.*\(dst\[2\], src\[2\]\)'))); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'mirror': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('flat LogicNet partial assignments stay in net connection flow', + () async { + final mod = PartialLogicNetRangeAssignment(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('assign dst[4:2] = src[4:2];'))); + expect(topBody, contains('net_connect')); + + final vectors = [ + for (final pattern in [0x00, 0x15, 0x2A, 0x3F]) + Vector({ + 'src': pattern + }, { + 'mirror': _expectedPartialArrayRangeValue(pattern, reversed: false), + }) ]; await SimCompare.checkFunctionalVector(mod, vectors); SimCompare.checkIverilogVector(mod, vectors); @@ -1001,6 +3288,39 @@ void main() { SimCompare.checkIverilogVector(mod, vectors); }); + for (final cfg in [ + (name: 'all zero', values: [0, 0, 0, 0]), + (name: 'mixed', values: [0, 0, 1, 0]), + ]) { + test( + 'constant leaf assignments into multidimensional array synthesize ' + '(${cfg.name})', () async { + final mod = ConstantLeafArrayAssignment( + values: cfg.values, + name: 'constant_leaf_array_assignment_${cfg.name.replaceAll(' ', '_')}', + ); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + for (final row in [0, 1]) { + for (final column in [0, 1]) { + expect(topBody, contains('banana[$row][$column]')); + } + } + expect(topBody, contains("1'h0")); + if (cfg.values.contains(1)) { + expect(topBody, contains("1'h1")); + } + + final vectors = [ + Vector({}, {'y': LogicValue.ofString(cfg.values.reversed.join())}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + } + group('array element inlining', () { /// Expected `~a` result for the [ArrayElementFanout] configurations, where /// leaves are inverted and optionally consumed in [reversed] order. @@ -1168,6 +3488,40 @@ void main() { }); group('aggregate connection inlining', () { + test('constant stays connected to single-element packed array input', + () async { + final mod = ConstantToSingleElementArrayInputTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains(".data((8'h0))")); + expect(topBody, isNot(contains('logic [0:0][7:0] data;'))); + + final vectors = [ + Vector({}, {'y': 0}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('nonzero constant stays connected to single-element packed array', + () async { + final mod = ConstantToSingleElementArrayInputTop(value: 0xa5); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains(".data((8'ha5))")); + expect(topBody, isNot(contains('logic [0:0][7:0] data;'))); + + final vectors = [ + Vector({}, {'y': 0xa5}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + final logicConfigs = <({String name, int n, int elementWidth, List? perm})>[ (name: '1d in order', n: 4, elementWidth: 1, perm: null), @@ -1248,6 +3602,42 @@ void main() { SimCompare.checkIverilogVector(mod, vectors); }); + test('ranged element sources are not collapsed into whole-source concat', + () async { + final mod = RangeSourcesToArrayPort(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains(RegExp(r'\.a\(\(\{\s*src,')))); + expect( + topBody, + contains(RegExp( + r'assign [A-Za-z_][A-Za-z0-9_$]*\[0\]\[15:0\] = src\[15:0\];')), + ); + expect( + topBody, + contains(RegExp( + r'assign [A-Za-z_][A-Za-z0-9_$]*\[3\]\[15:0\] = src\[63:48\];')), + ); + + final patterns = [ + LogicValue.filled(64, LogicValue.zero), + LogicValue.ofInt(0x123456789abc, 64), + LogicValue.filled(64, LogicValue.one), + ]; + final vectors = [ + for (final pattern in patterns) + Vector({ + 'src': pattern, + }, { + 'y': ~pattern, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + final netConfigs = <({String name, int n, List? perm})>[ (name: '1d in order', n: 4, perm: null), (name: '1d out of order', n: 4, perm: [2, 3, 0, 1]), @@ -1432,7 +3822,7 @@ void main() { // whole to a child inout port collapses into a single inline concatenation // of those nets. for (final busNaming in [Naming.mergeable, Naming.renameable]) { - test('whole net bus to port collapses ($busNaming)', () async { + test('whole net bus to port respects naming ($busNaming)', () async { const n = 8; final mod = WholeNetBusToPort( List.generate(n, (_) => LogicNet()), LogicNet(width: n), @@ -1441,11 +3831,17 @@ void main() { final sv = mod.generateSynth(); final topBody = _topModuleBody(sv); - // the bus and its per-bit net_connects are gone, replaced by a single - // inline concatenation on the child port - expect(topBody, isNot(contains('net_connect'))); - expect(topBody, isNot(contains('wire [7:0] bus'))); - expect(topBody, contains('.data(({')); + if (busNaming == Naming.mergeable) { + // The bus and its per-bit net_connects are gone, replaced by a + // single inline concatenation on the child port. + expect(topBody, isNot(contains('net_connect'))); + expect(topBody, isNot(contains('wire [7:0] bus'))); + expect(topBody, contains('.data(({')); + } else { + expect(topBody, contains('net_connect')); + expect(topBody, contains('wire [7:0] bus')); + expect(topBody, contains('.data(bus)')); + } final vectors = [ for (final pattern in [0x0, 0xA, 0x5, 0xFF, 0x3C]) @@ -1460,6 +3856,44 @@ void main() { }); } + test('whole net bus collapse preserves inline subset consumers', () async { + const n = 4; + final mod = WholeNetBusToPortWithInlineSubsetConsumer( + List.generate(n, (_) => LogicNet()), LogicNet(width: n)); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data')); + expect(topBody, contains('enable &')); + + final vectors = [ + for (final enable in [0, 1]) + for (final pattern in [0x0, 0x5, 0xA, 0xF]) + Vector({ + 'enable': enable, + for (var i = 0; i < n; i++) 'net$i': (pattern >> i) & 1, + }, { + 'mirror': pattern, + 'z': enable == 0 ? 0 : (~pattern) & 1, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('whole net bus collapse ignores read-only subset consumers', () async { + const n = 4; + final mod = + WholeNetBusToPortWithReadOnlyInlineSubsetConsumer(LogicNet(width: n)); + await mod.build(); + final topBody = _topModuleBody(mod.generateSynth()); + + expect(topBody, contains('wire [3:0] bus')); + expect(topBody, contains(RegExp('net_connect.*_subset_0_0_bus'))); + expect(topBody, contains('enable &')); + }); + test('reserved-named whole net bus is not collapsed', () async { const n = 8; final mod = WholeNetBusToPort( @@ -1516,7 +3950,8 @@ void main() { // child inout *array* port traces through the pass-through bus and // collapses into a single inline concatenation of those nets. for (final busNaming in [Naming.mergeable, Naming.renameable]) { - test('bitwise net bus into array port collapses ($busNaming)', () async { + test('bitwise net bus into array port respects naming ($busNaming)', + () async { const n = 8; final mod = BitwiseNetBusToArrayPort( List.generate(n, (_) => LogicNet()), LogicNet(width: n), @@ -1525,11 +3960,17 @@ void main() { final sv = mod.generateSynth(); final topBody = _topModuleBody(sv); - // the bus and its net_connects are traced away and replaced by a - // single inline concatenation of those nets on the child array port - expect(topBody, isNot(contains('net_connect'))); - expect(topBody, isNot(contains('wire [7:0] bus'))); - expect(topBody, contains('.data(({')); + if (busNaming == Naming.mergeable) { + // The bus and its net_connects are traced away and replaced by a + // single inline concatenation of those nets on the child array port. + expect(topBody, isNot(contains('net_connect'))); + expect(topBody, isNot(contains('wire [7:0] bus'))); + expect(topBody, contains('.data(({')); + } else { + expect(topBody, contains('net_connect')); + expect(topBody, contains('wire [7:0] bus')); + expect(topBody, isNot(contains('.data(({'))); + } final vectors = [ for (final pattern in [0x0, 0xA, 0x5, 0xFF, 0x3C]) @@ -1544,6 +3985,33 @@ void main() { }); } + test('bitwise net bus collapse preserves inline subset consumers', + () async { + const n = 4; + final mod = BitwiseNetBusToArrayPortWithInlineSubsetConsumer( + List.generate(n, (_) => LogicNet()), LogicNet(width: n)); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.data')); + expect(topBody, contains('enable &')); + + final vectors = [ + for (final enable in [0, 1]) + for (final pattern in [0x0, 0x5, 0xA, 0xF]) + Vector({ + 'enable': enable, + for (var i = 0; i < n; i++) 'net$i': (pattern >> i) & 1, + }, { + 'mirror': pattern, + 'z': enable == 0 ? 0 : (~pattern) & 1, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + test('reserved-named bitwise net bus into array port is not collapsed', () async { const n = 8; @@ -1758,6 +4226,330 @@ void main() { SimCompare.checkIverilogVector(mod, vectors); }); + test('assignSubset into late child input source maps instance input', + () async { + final mod = LateSubsetInputTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, contains('.data(source)')); + + final vectors = [ + for (final pattern in [0x00, 0x01, 0x02, 0xff]) + Vector({'source': pattern}, {'y': pattern & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('assignSubset slice into late child input source keeps mapping', + () async { + final mod = LateSlicedSubsetInputTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, contains('assign')); + expect(topBody, contains('source[11:4]')); + + final vectors = [ + for (final pattern in [0x0000, 0x0010, 0x00f0, 0xffff]) + Vector({'source': pattern}, {'y': (pattern >> 4) & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling output can drive subset of sibling input source', () async { + final mod = SiblingOutputToInputSubsetTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, isNot(contains('.result()'))); + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0xf]) + Vector({'source': pattern}, {'y': pattern & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling full output can drive sibling full input source', () async { + final mod = SiblingFullOutputToInputSubsetTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, isNot(contains('.result()'))); + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0xf]) + Vector({'source': pattern}, {'y': pattern & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling output stays connected beside range assignments', () async { + final mod = SiblingOutputWithRangeAssignmentsTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.result(bus[7])')); + expect(topBody, contains('.data(bus)')); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x00, 0x01, 0x04, 0x7f]) + for (final bit in [0, 1]) + Vector({ + 'source': pattern, + 'seed': bit, + }, { + 'y': bit, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + for (final outputIndex in [0, 3]) { + test('sibling output at bit $outputIndex stays directly connected', + () async { + final mod = IndexedSiblingOutputWithRangeAssignmentsTop(outputIndex); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, contains('.result(bus[$outputIndex])')); + expect(topBody, contains('.data(bus)')); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x00, 0x01, 0x35, 0x7f]) + for (final bit in [0, 1]) + Vector({ + 'source': pattern, + 'seed': bit, + }, { + 'y': (pattern & ((1 << outputIndex) - 1)) | + (bit << outputIndex) | + ((pattern >> outputIndex) << (outputIndex + 1)), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + } + + test('multiple sibling outputs stay connected in packed concat', () async { + final mod = MultipleSiblingOutputsWithRangeAssignmentsTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.result()'))); + expect(topBody, contains('.data(({')); + expect(topBody, isNot(contains('logic [7:0] bus;'))); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x00, 0x01, 0x15, 0x3f]) + for (final bit0 in [0, 1]) + for (final bit1 in [0, 1]) + Vector({ + 'sourceLow': pattern & 0x3, + 'sourceHigh': pattern >> 2, + 'seed0': bit0, + 'seed1': bit1, + }, { + 'y': (pattern & 0x3) | + (bit0 << 2) | + ((pattern >> 2) << 3) | + (bit1 << 7), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling output fanout stays connected after range collapse', + () async { + final mod = FanoutSiblingOutputWithRangeAssignmentsTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.result()'))); + expect(topBody, contains('.data(({')); + expect(topBody, isNot(contains('logic [7:0] bus;'))); + expect(topBody, isNot(contains('_subset'))); + + final vectors = [ + for (final pattern in [0x00, 0x01, 0x35, 0x7f]) + for (final bit in [0, 1]) + Vector({ + 'source': pattern, + 'seed': bit, + }, { + 'y': pattern | (bit << 7), + 'fanout': bit, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('wide sibling output stays connected after range collapse', () async { + final mod = WideSiblingOutputWithRangeAssignmentsTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.result()'))); + expect(topBody, contains('.data(bus)')); + expect(topBody, contains('bus[5:2]')); + expect(topBody, isNot(contains(RegExp(r'\bbus_subset\b')))); + + final vectors = [ + for (final source in [0x0, 0x1, 0xa, 0xf]) + for (final seed in [0x0, 0x3, 0xc, 0xf]) + Vector({ + 'source': source, + 'seed': seed, + }, { + 'y': (source & 0x3) | (seed << 2) | ((source >> 2) << 6), + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('wide sibling output keeps fanout between constant ranges', () async { + final mod = WideSiblingOutputWithConstantsAndFanoutTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.result()'))); + expect(topBody, contains('.data(({')); + expect(RegExp("2'h2").allMatches(topBody), hasLength(greaterThan(1))); + expect(topBody, isNot(contains('logic [7:0] bus;'))); + expect(topBody, isNot(contains('logic [1:0] tie;'))); + expect(topBody, isNot(contains(RegExp(r'\bbus_subset\b')))); + + final vectors = [ + for (final seed in [0x0, 0x1, 0x5, 0xa, 0xf]) + Vector({ + 'seed': seed, + }, { + 'y': 2 | (seed << 2) | (2 << 6), + 'fanout': seed, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling output array can drive subset of sibling input array source', + () async { + final mod = SiblingArrayOutputToInputSubsetTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, isNot(contains('.result()'))); + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0xf]) + Vector({'source': pattern}, {'y': pattern & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling output structure can drive sibling input structure source', + () async { + final mod = SiblingStructOutputToInputSubsetTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, isNot(contains('.result()'))); + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0x3]) + Vector({'source': pattern}, {'y': (pattern >> 1) & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling inout can drive subset of sibling inout source', () async { + final mod = SiblingInOutToInOutSubsetTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + expect(topBody, isNot(contains('.data()'))); + expect(topBody, isNot(contains('.link()'))); + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0xf]) + Vector({'source': pattern}, {'y': pattern & 1}) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + + test('sibling boundary kitchen sink keeps mixed source mappings', () async { + final mod = SiblingBoundaryProductTop(); + await mod.build(); + final sv = mod.generateSynth(); + final topBody = _topModuleBody(sv); + + for (final portName in [ + 'wide', + 'arr', + 'pair', + 'link', + 'wide_from_scalar', + 'array_from_scalar', + 'struct_from_array', + 'wide_from_struct', + 'net_from_net', + ]) { + expect(topBody, isNot(contains('.$portName()'))); + } + + final vectors = [ + for (final pattern in [0x0, 0x1, 0x2, 0x4, 0xf]) + for (final netPattern in [0x0, 0x2, 0xf]) + Vector({ + 'source': pattern, + 'net_source': netPattern, + }, { + 'scalar_bit': (pattern >> 1) & 1, + 'array_bit': (pattern >> 2) & 1, + 'struct_bit': (pattern >> 2) & 1, + 'wide_struct_bit': (pattern >> 1) & 1, + 'net_bit': (netPattern >> 1) & 1, + }) + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + SimCompare.checkIverilogVector(mod, vectors); + }); + test('partial assignSubset is conservatively preserved (undriven stays z)', () async { const n = 4; @@ -1769,7 +4561,7 @@ void main() { // not every element is a pass-through, so the subset array is preserved expect(topBody, contains('_subset')); - expect(topBody, contains('bus_subset[3]')); + expect(topBody, contains('bus_subset[3')); expect(topBody, contains('net_connect')); final vectors = [ diff --git a/test/benchmark_test.dart b/test/benchmark_test.dart index 4a54fcfe3..a65020166 100644 --- a/test/benchmark_test.dart +++ b/test/benchmark_test.dart @@ -20,7 +20,7 @@ import '../benchmark/ssa_driver_search_benchmark.dart'; import '../benchmark/wave_dump_benchmark.dart'; void main() { - group('benchmark', () { + group('benchmark', tags: 'benchmark', () { test('pipeline', () async { await PipelineBenchmark().measure(); }); diff --git a/test/logic_array_test.dart b/test/logic_array_test.dart index 87c6be85a..de5d72513 100644 --- a/test/logic_array_test.dart +++ b/test/logic_array_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // logic_array_test.dart @@ -847,8 +847,8 @@ void main() { // ensure ports with interface are still an array final sv = mod.generateSynth(); - expect(sv, contains('input logic [2:0][1:0][2:0][7:0] laIn')); - expect(sv, contains('output logic [2:0][1:0][2:0][7:0] laOut')); + expect(sv, contains('input wire logic [2:0][1:0][2:0][7:0] laIn')); + expect(sv, contains('output var logic [2:0][1:0][2:0][7:0] laOut')); }); test('3 dimensions with interface and unpacked', () async { @@ -862,8 +862,8 @@ void main() { // ensure ports with interface are still an array final sv = mod.generateSynth(); - expect(sv, contains('input logic [1:0][2:0][7:0] laIn [2:0]')); - expect(sv, contains('output logic [1:0][2:0][7:0] laOut [2:0]')); + expect(sv, contains('input wire logic [1:0][2:0][7:0] laIn [2:0]')); + expect(sv, contains('output var logic [1:0][2:0][7:0] laOut [2:0]')); }); }); diff --git a/test/logic_test.dart b/test/logic_test.dart index 5e21fb39f..cd3169cab 100644 --- a/test/logic_test.dart +++ b/test/logic_test.dart @@ -15,4 +15,26 @@ void main() { final logic = Logic(); expect(logic.packed, logic); }); + + test('getRange on filled constants returns a constant', () { + for (final fillValue in [ + LogicValue.zero, + LogicValue.one, + LogicValue.x, + LogicValue.z, + ]) { + final range = Const(LogicValue.filled(8, fillValue)).getRange(2, 5); + + expect(range, isA()); + expect(range.value, LogicValue.filled(3, fillValue)); + expect(range.parentModule, isNull); + } + }); + + test('getRange on mixed constants still uses BusSubset', () { + final range = Const(LogicValue.ofString('10101010')).getRange(2, 5); + + expect(range, isNot(isA())); + expect(range.parentModule, isA()); + }); } diff --git a/test/naming_cases_test.dart b/test/naming_cases_test.dart index fbc1d9536..28128e0f5 100644 --- a/test/naming_cases_test.dart +++ b/test/naming_cases_test.dart @@ -557,8 +557,8 @@ void main() { final sv = mod.generateSynth(); // Port declarations. - expect(sv, contains('input logic [7:0] inp')); - expect(sv, contains('output logic [7:0] out')); + expect(sv, contains('input wire logic [7:0] inp')); + expect(sv, contains('output var logic [7:0] out')); expect(sv, contains('_uinp')); expect(sv, contains('mport')); expect(sv, contains('_muprt')); diff --git a/test/net_bus_test.dart b/test/net_bus_test.dart index 2d5fccdc0..73e8c451e 100644 --- a/test/net_bus_test.dart +++ b/test/net_bus_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2024-2025 Intel Corporation +// Copyright (C) 2024-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // net_bus_test.dart @@ -601,7 +601,7 @@ void main() { sv, contains('net_connect_0' ' (_original__swizzled, ' - '({bus[1][1],bus[1][0],bus[0][3],bus[0][2]}));')); + '({bus[1][1:0],bus[0][3:2]}));')); } final vectors = [ @@ -631,7 +631,7 @@ void main() { sv, contains('net_connect_0' ' (_original__swizzled, ' - '({bus[1][1],bus[1][0],bus[0][3],bus[0][2]}));')); + '({bus[1][1:0],bus[0][3:2]}));')); } final vectors = [ @@ -788,7 +788,7 @@ void main() { sv, contains('assign _swizzled = ' '{({({in0[1][1],in0[1][0]}),({in0[0][1],in0[0][0]})}),' - '({in1[3],in1[2],in1[1],in1[0]})};')); + '(in1[3:0])};')); }); test('net array 2', () async { @@ -805,7 +805,7 @@ void main() { expect( sv, contains('net_connect (swizzled,' - ' ({({in0[3],in0[2],in0[1],in0[0]}),in1[0]}));')); + ' ({(in0[3:0]),in1[0]}));')); }); test('net array 3', () async { @@ -825,7 +825,7 @@ void main() { contains('net_connect (swizzled, ' '({({({in0[1][1],in0[1][0]}),' '({in0[0][1],in0[0][0]})}),' - '({in1[3],in1[2],in1[1],in1[0]}),in2[0]}));')); + '(in1[3:0]),in2[0]}));')); }); test('net and non-net', () async { @@ -931,7 +931,7 @@ void main() { 'net_connect #(.WIDTH(16)) net_connect (swizzled, ' '({({({in0[1][1],in0[1][0]}),' '({in0[0][1],in0[0][0]})}),' - '({in1[3],in1[2],in1[1],in1[0]}),in2[0]}));')); + '(in1[3:0]),in2[0]}));')); } } diff --git a/test/pair_interface_hier_test.dart b/test/pair_interface_hier_test.dart index e665b7102..41567b089 100644 --- a/test/pair_interface_hier_test.dart +++ b/test/pair_interface_hier_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // pair_interface_hier_test.dart @@ -95,7 +95,7 @@ void main() { expect(sv, contains('HierConsumer unnamed_module')); expect(sv, contains('HierProducer unnamed_module')); - expect(sv, contains('inout wire io_0')); - expect(sv, contains('inout wire [2:0] io_arr_0')); + expect(sv, contains('inout wire logic io_0')); + expect(sv, contains('inout wire logic [2:0] io_arr_0')); }); } diff --git a/test/pair_interface_hier_w_modify_test.dart b/test/pair_interface_hier_w_modify_test.dart index 47c65318c..a3926ba05 100644 --- a/test/pair_interface_hier_w_modify_test.dart +++ b/test/pair_interface_hier_w_modify_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // pair_interface_hier_w_modify_test.dart @@ -97,7 +97,7 @@ void main() { expect(sv, contains('HierConsumer unnamed_module')); expect(sv, contains('HierProducer unnamed_module')); - expect(sv, contains('inout wire io_0')); - expect(sv, contains('inout wire [2:0] io_arr_0')); + expect(sv, contains('inout wire logic io_0')); + expect(sv, contains('inout wire logic [2:0] io_arr_0')); }); } diff --git a/test/pair_interface_test.dart b/test/pair_interface_test.dart index 0167335f3..724c1f5cb 100644 --- a/test/pair_interface_test.dart +++ b/test/pair_interface_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // pair_interface_test.dart @@ -193,7 +193,7 @@ void main() { // Make sure the "modify" went through: final sv = mod.generateSynth(); - expect(sv, contains('input logic simple_clk')); + expect(sv, contains('input wire logic simple_clk')); }); group('drive and receive other', () { diff --git a/test/provider_consumer_test.dart b/test/provider_consumer_test.dart index 97c15f648..a82b4536e 100644 --- a/test/provider_consumer_test.dart +++ b/test/provider_consumer_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // provider_consumer_test.dart @@ -180,33 +180,33 @@ void main() { expect(sv, contains(''' module Provider ( -input logic clk, -input logic reset, -input logic wd0_ready_req, -input logic wd1_ready_req, -input logic [31:0] rd_data_rsp, -input logic rd_valid_rsp, -output logic [31:0] wd0_data_req, -output logic wd0_valid_req, -output logic [31:0] wd1_data_req, -output logic wd1_valid_req, -output logic rd_ready_rsp +input wire logic clk, +input wire logic reset, +input wire logic wd0_ready_req, +input wire logic wd1_ready_req, +input wire logic [31:0] rd_data_rsp, +input wire logic rd_valid_rsp, +output var logic [31:0] wd0_data_req, +output var logic wd0_valid_req, +output var logic [31:0] wd1_data_req, +output var logic wd1_valid_req, +output var logic rd_ready_rsp ); ''')); expect(sv, contains(''' module Consumer ( -input logic clk, -input logic reset, -input logic [31:0] wd0_data_req, -input logic wd0_valid_req, -input logic [31:0] wd1_data_req, -input logic wd1_valid_req, -input logic rd_ready_rsp, -output logic wd0_ready_req, -output logic wd1_ready_req, -output logic [31:0] rd_data_rsp, -output logic rd_valid_rsp +input wire logic clk, +input wire logic reset, +input wire logic [31:0] wd0_data_req, +input wire logic wd0_valid_req, +input wire logic [31:0] wd1_data_req, +input wire logic wd1_valid_req, +input wire logic rd_ready_rsp, +output var logic wd0_ready_req, +output var logic wd1_ready_req, +output var logic [31:0] rd_data_rsp, +output var logic rd_valid_rsp ); ''')); diff --git a/test/provider_consumer_w_modify_test.dart b/test/provider_consumer_w_modify_test.dart index d34c8e374..d750c000d 100644 --- a/test/provider_consumer_w_modify_test.dart +++ b/test/provider_consumer_w_modify_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // provider_consumer_w_modify_test.dart @@ -150,33 +150,33 @@ void main() { expect(sv, contains(''' module Provider ( -input logic clk, -input logic reset, -input logic wd0_ready_req, -input logic wd1_ready_req, -input logic [31:0] rd_data_rsp, -input logic rd_valid_rsp, -output logic [31:0] wd0_data_req, -output logic wd0_valid_req, -output logic [31:0] wd1_data_req, -output logic wd1_valid_req, -output logic rd_ready_rsp +input wire logic clk, +input wire logic reset, +input wire logic wd0_ready_req, +input wire logic wd1_ready_req, +input wire logic [31:0] rd_data_rsp, +input wire logic rd_valid_rsp, +output var logic [31:0] wd0_data_req, +output var logic wd0_valid_req, +output var logic [31:0] wd1_data_req, +output var logic wd1_valid_req, +output var logic rd_ready_rsp ); ''')); expect(sv, contains(''' module Consumer ( -input logic clk, -input logic reset, -input logic [31:0] wd0_data_req, -input logic wd0_valid_req, -input logic [31:0] wd1_data_req, -input logic wd1_valid_req, -input logic rd_ready_rsp, -output logic wd0_ready_req, -output logic wd1_ready_req, -output logic [31:0] rd_data_rsp, -output logic rd_valid_rsp +input wire logic clk, +input wire logic reset, +input wire logic [31:0] wd0_data_req, +input wire logic wd0_valid_req, +input wire logic [31:0] wd1_data_req, +input wire logic wd1_valid_req, +input wire logic rd_ready_rsp, +output var logic wd0_ready_req, +output var logic wd1_ready_req, +output var logic [31:0] rd_data_rsp, +output var logic rd_valid_rsp ); ''')); diff --git a/test/sv_gen_test.dart b/test/sv_gen_test.dart index 6ad38737a..13fe8032f 100644 --- a/test/sv_gen_test.dart +++ b/test/sv_gen_test.dart @@ -887,8 +887,8 @@ void main() { expect(sv, contains(''' module ModWithUselessWireMods ( -input logic [7:0] a, -input logic [7:0] b +input wire logic [7:0] a, +input wire logic [7:0] b ); endmodule : ModWithUselessWireMods''')); diff --git a/test/swizzle_test.dart b/test/swizzle_test.dart index 6e1fc0949..d6ccf9d4f 100644 --- a/test/swizzle_test.dart +++ b/test/swizzle_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2022-2025 Intel Corporation +// Copyright (C) 2022-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // swizzle_test.dart @@ -9,6 +9,7 @@ import 'package:rohd/rohd.dart'; import 'package:rohd/src/utilities/simcompare.dart'; +import 'package:rohd/src/utilities/sv_cleaner.dart'; import 'package:test/test.dart'; class SwizzlyModule extends Module { @@ -113,6 +114,70 @@ class LargeWidthSwizzle extends Module { } } +class SwizzleAdjacentBitSlices extends Module { + SwizzleAdjacentBitSlices() { + final a = addInput('a', Logic(width: 8), width: 8); + addOutput('out', width: 6) <= + [a[7], a[6], a[5], a[2], a[1], a[0]].swizzle(); + } +} + +class SwizzleAllAdjacentBitSlices extends Module { + SwizzleAllAdjacentBitSlices() { + final a = addInput('a', Logic(width: 8), width: 8); + addOutput('out', width: 3) <= [a[7], a[6], a[5]].swizzle(); + } +} + +class SwizzleAscendingBitSlices extends Module { + SwizzleAscendingBitSlices() { + final a = addInput('a', Logic(width: 8), width: 8); + addOutput('out', width: 3) <= [a[0], a[1], a[2]].swizzle(); + } +} + +class SwizzleNestedAdjacentBitSlices extends Module { + SwizzleNestedAdjacentBitSlices() { + final a = addInput('a', Logic(width: 8), width: 8); + final inner = Swizzle([a[7], a[6], a[5]]).out; + addOutput('out', width: 5) <= [inner, a[4], a[3]].swizzle(); + } +} + +class SwizzleAdjacentRanges extends Module { + SwizzleAdjacentRanges() { + final a = addInput('a', Logic(width: 8), width: 8); + addOutput('out', width: 6) <= [a.slice(5, 2), a.slice(1, 0)].swizzle(); + } +} + +class SwizzlePackedArrayElementBits extends Module { + SwizzlePackedArrayElementBits(LogicArray arr) { + final inArr = addInputArray('arr', arr, dimensions: [2, 2]); + final upper = inArr.elements[1] as LogicArray; + final lower = inArr.elements[0] as LogicArray; + addOutput('out', width: 4) <= + [ + upper.elements[1], + upper.elements[0], + lower.elements[1], + lower.elements[0], + ].swizzle(); + } +} + +class SwizzleUnpackedArrayElements extends Module { + SwizzleUnpackedArrayElements(LogicArray arr) { + final inArr = addInputArray( + 'arr', + arr, + dimensions: [4], + numUnpackedDimensions: 1, + ); + addOutput('out', width: 4) <= inArr.elements.reversed.toList().swizzle(); + } +} + void main() { tearDown(() async { await Simulator.reset(); @@ -319,6 +384,146 @@ void main() { }); }); + group('SystemVerilog slice collapsing', () { + test('collapses descending contiguous bit selects', () async { + final mod = SwizzleAdjacentBitSlices(); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, contains('assign out = {a[7:5],a[2:0]};')); + expect(sv, isNot(contains('a[7],a[6]'))); + + final vectors = [ + for (final value in [0x00, 0xe5, 0x3c, 0xff]) + Vector({ + 'a': value + }, { + 'out': (((value >> 5) & 0x7) << 3) | (value & 0x7), + }), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('omits braces when the whole swizzle collapses to one slice', + () async { + final mod = SwizzleAllAdjacentBitSlices(); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, contains('assign out = a[7:5];')); + expect(sv, isNot(contains('assign out = {a[7:5]};'))); + + final vectors = [ + for (final value in [0x00, 0xe0, 0xa0, 0xff]) + Vector({'a': value}, {'out': (value >> 5) & 0x7}), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('does not collapse ascending bit selects', () async { + final mod = SwizzleAscendingBitSlices(); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, isNot(contains('a[2:0]'))); + expect(sv, contains('a[0]')); + expect(sv, contains('a[1]')); + expect(sv, contains('a[2]')); + + final vectors = [ + for (final value in [0x00, 0x05, 0x06, 0xff]) + Vector({ + 'a': value + }, { + 'out': ((value & 0x1) << 2) | + (((value >> 1) & 0x1) << 1) | + ((value >> 2) & 0x1), + }), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('collapses nested swizzle internals but not across the nested range', + () async { + final mod = SwizzleNestedAdjacentBitSlices(); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, contains('assign out = {(a[7:5]),a[4:3]};')); + expect(sv, isNot(contains('a[7:3]'))); + + final vectors = [ + for (final value in [0x00, 0xf8, 0xa8, 0xff]) + Vector({'a': value}, {'out': (value >> 3) & 0x1f}), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('does not re-collapse adjacent range operands', () async { + final mod = SwizzleAdjacentRanges(); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, contains('assign out = {(a[5:2]),(a[1:0])};')); + expect(sv, isNot(contains('a[5:0]'))); + + final vectors = [ + for (final value in [0x00, 0x15, 0x2a, 0xff]) + Vector({'a': value}, {'out': value & 0x3f}), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('collapses packed array element bit selects within each element', + () async { + final mod = SwizzlePackedArrayElementBits(LogicArray([2, 2], 1)); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, contains('assign out = {arr[1][1:0],arr[0][1:0]};')); + expect(sv, isNot(contains('arr[1][1],arr[1][0]'))); + + final vectors = [ + for (final value in [0x0, 0x5, 0xa, 0xf]) + Vector({'arr': value}, {'out': value}), + ]; + await SimCompare.checkFunctionalVector(mod, vectors); + final simResult = SimCompare.iverilogVector(mod, vectors); + expect(simResult, equals(true)); + }); + + test('does not collapse unpacked array element selects', () async { + final mod = SwizzleUnpackedArrayElements( + LogicArray([4], 1, numUnpackedDimensions: 1), + ); + await mod.build(); + + final sv = SvCleaner.removeSwizzleAnnotationComments(mod.generateSynth()); + + expect(sv, isNot(contains('arr[3:0]'))); + expect(sv, contains('arr[3]')); + expect(sv, contains('arr[2]')); + expect(sv, contains('arr[1]')); + expect(sv, contains('arr[0]')); + }); + }); + test('annotated elements of swizzle in generated sv', () async { final mod = SwizzleVariety(Logic(width: 8)); await mod.build(); diff --git a/test/systemverilog_port_types_test.dart b/test/systemverilog_port_types_test.dart new file mode 100644 index 000000000..17f586cbd --- /dev/null +++ b/test/systemverilog_port_types_test.dart @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// systemverilog_port_types_test.dart +// Tests for SystemVerilog port object and data types. +// +// 2026 July +// Author: Max Korbel + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/simcompare.dart'; +import 'package:test/test.dart'; + +class _PortStructure extends LogicStructure { + final bool asNet; + + factory _PortStructure({String? name, bool asNet = false}) => + _PortStructure._( + (asNet ? LogicNet.new : Logic.new)(width: 2, name: 'first'), + (asNet ? LogicNet.new : Logic.new)(width: 6, name: 'second'), + name: name, + asNet: asNet, + ); + + _PortStructure._(Logic first, Logic second, + {required String? name, required this.asNet}) + : super([first, second], name: name ?? 'portStructure'); + + @override + _PortStructure clone({String? name}) => + _PortStructure(name: name ?? this.name, asNet: asNet); +} + +class _PortTypesModule extends Module { + _PortTypesModule({bool includeUnpackedInOut = true}) { + final scalarIn = addInput('scalarIn', Logic(width: 8), width: 8); + addOutput('scalarOut', width: 8) <= scalarIn; + addInOut('scalarInOut', LogicNet(width: 8), width: 8); + + final structureIn = addTypedInput('structureIn', _PortStructure()); + addTypedOutput('structureOut', structureIn.clone) <= structureIn; + addTypedInOut('structureInOut', _PortStructure(asNet: true)); + + final packedArrayIn = addTypedInput('packedArrayIn', LogicArray([2, 3], 4)); + addTypedOutput('packedArrayOut', packedArrayIn.clone) <= packedArrayIn; + addTypedInOut('packedArrayInOut', LogicArray.net([2, 3], 4)); + + final unpackedArrayIn = addTypedInput( + 'unpackedArrayIn', LogicArray([2, 3], 4, numUnpackedDimensions: 1)); + addTypedOutput('unpackedArrayOut', unpackedArrayIn.clone) <= + unpackedArrayIn; + if (includeUnpackedInOut) { + addTypedInOut('unpackedArrayInOut', + LogicArray.net([2, 3], 4, numUnpackedDimensions: 1)); + } + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + final testCases = [ + ( + name: 'explicit object and data types by default', + configuration: const SystemVerilogSynthesizerConfiguration(), + inputPrefix: 'input wire logic', + outputPrefix: 'output var logic', + inOutPrefix: 'inout wire logic', + ), + ( + name: 'implicit object and explicit data types', + configuration: const SystemVerilogSynthesizerConfiguration( + portObjectType: SystemVerilogPortType.implicit, + ), + inputPrefix: 'input logic', + outputPrefix: 'output logic', + inOutPrefix: 'inout logic', + ), + ( + name: 'explicit object and implicit data types', + configuration: const SystemVerilogSynthesizerConfiguration( + portDataType: SystemVerilogPortType.implicit, + ), + inputPrefix: 'input wire', + outputPrefix: 'output var', + inOutPrefix: 'inout wire', + ), + ( + name: 'implicit object and data types', + configuration: const SystemVerilogSynthesizerConfiguration( + portObjectType: SystemVerilogPortType.implicit, + portDataType: SystemVerilogPortType.implicit, + ), + inputPrefix: 'input', + outputPrefix: 'output', + inOutPrefix: 'inout', + ), + ]; + + for (final testCase in testCases) { + test(testCase.name, () async { + final module = _PortTypesModule(); + await module.build(); + + final sv = module.generateSynth(configuration: testCase.configuration); + + final declarations = { + testCase.inputPrefix: [ + '[7:0] scalarIn', + '[7:0] structureIn', + '[1:0][2:0][3:0] packedArrayIn', + '[2:0][3:0] unpackedArrayIn [1:0]', + ], + testCase.outputPrefix: [ + '[7:0] scalarOut', + '[7:0] structureOut', + '[1:0][2:0][3:0] packedArrayOut', + '[2:0][3:0] unpackedArrayOut [1:0]', + ], + testCase.inOutPrefix: [ + '[7:0] scalarInOut', + '[7:0] structureInOut', + '[1:0][2:0][3:0] packedArrayInOut', + '[2:0][3:0] unpackedArrayInOut [1:0]', + ], + }; + + for (final MapEntry(key: prefix, value: suffixes) + in declarations.entries) { + for (final suffix in suffixes) { + expect(sv, contains('$prefix $suffix')); + } + } + + final iverilogModule = _PortTypesModule(includeUnpackedInOut: false); + await iverilogModule.build(); + SimCompare.checkIverilogVector( + iverilogModule, + [], + buildOnly: true, + synthesizerConfiguration: testCase.configuration, + ); + }); + } +} diff --git a/test/typed_port_test.dart b/test/typed_port_test.dart index ff31896d5..67aba51ab 100644 --- a/test/typed_port_test.dart +++ b/test/typed_port_test.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // typed_port_test.dart @@ -233,8 +233,8 @@ void main() { expect(sv, isNot(contains('internal_struct'))); - expect(sv, contains('input logic [1:0] myIn')); - expect(sv, contains('output logic [1:0] myOut')); + expect(sv, contains('input wire logic [1:0] myIn')); + expect(sv, contains('output var logic [1:0] myOut')); final vectors = [ Vector({'a1': 0, 'a2': 1}, {'b1': 1, 'b2': 0}), @@ -253,8 +253,8 @@ void main() { final sv = mod.generateSynth(); - expect(sv, contains('input logic [3:0][1:0] anyIn')); - expect(sv, contains('output logic [3:0][1:0] anyOut')); + expect(sv, contains('input wire logic [3:0][1:0] anyIn')); + expect(sv, contains('output var logic [3:0][1:0] anyOut')); expect(sv, contains('assign anyOut = anyIn;')); }); @@ -284,13 +284,14 @@ void main() { sv, isNot(contains(RegExp(r'\b(?:input|output|inout)\s+.*BADNAME')))); // if name is renamed/uniquified, it won't be an exact match - expect( - sv, contains(RegExp(r'^\s*input logic inp[,\s]*$', multiLine: true))); - expect( - sv, contains(RegExp(r'^\s*output logic out1[,\s]*$', multiLine: true))); - expect( - sv, contains(RegExp(r'^\s*output logic out2[,\s]*$', multiLine: true))); - expect(sv, contains(RegExp(r'^\s*inout wire io[,\s]*$', multiLine: true))); + expect(sv, + contains(RegExp(r'^\s*input wire logic inp[,\s]*$', multiLine: true))); + expect(sv, + contains(RegExp(r'^\s*output var logic out1[,\s]*$', multiLine: true))); + expect(sv, + contains(RegExp(r'^\s*output var logic out2[,\s]*$', multiLine: true))); + expect(sv, + contains(RegExp(r'^\s*inout wire logic io[,\s]*$', multiLine: true))); }); test('packed struct output inside and outside of module', () async { @@ -361,8 +362,8 @@ void main() { expect(sv, isNot(contains('internal_struct'))); - expect(sv, contains('inout wire [1:0] myIn')); - expect(sv, contains('inout wire [1:0] myOut')); + expect(sv, contains('inout wire logic [1:0] myIn')); + expect(sv, contains('inout wire logic [1:0] myOut')); return mod; } diff --git a/tool/gh_actions/devtool/build_web.sh b/tool/gh_actions/devtool/build_web.sh deleted file mode 100755 index f11186c63..000000000 --- a/tool/gh_actions/devtool/build_web.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: BSD-3-Clause -# -# build_web.sh -# Build DevTool static web. -# -# 2024 January 03 -# Author: Yao Jing Quek - -set -euo pipefail - -cd rohd_devtools_extension - -flutter pub get - -dart run devtools_extensions build_and_copy --source=. --dest=../extension/devtools \ No newline at end of file diff --git a/tool/gh_actions/devtool/install_devtools.sh b/tool/gh_actions/devtool/install_devtools.sh new file mode 100755 index 000000000..6174a7c53 --- /dev/null +++ b/tool/gh_actions/devtool/install_devtools.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Copyright (C) 2024-2026 Intel Corporation +# SPDX-License-Identifier: BSD-3-Clause +# +# install_devtools.sh +# Build the ROHD DevTools extension web artifact: +# extension/devtools/ – DevTools extension (iframe in Chrome DevTools) +# +# Usage (from repo root): +# bash tool/gh_actions/devtool/install_devtools.sh +# +# 2024 January 03 +# Author: Yao Jing Quek + +set -euo pipefail + +DEST="../extension/devtools" + +# ═══════════════════════════════════════════════════════════════════════ +# Build starts here +# ═══════════════════════════════════════════════════════════════════════ + +cd rohd_devtools_extension + +flutter pub get + +echo "" +echo "════════════════════════════════════════════════════════════" +echo " Building DevTools extension..." +echo "════════════════════════════════════════════════════════════" + +flutter build web --pwa-strategy=none --release --no-tree-shake-icons + +if [ ! -f build/web/canvaskit/canvaskit.js ] || [ ! -f build/web/canvaskit/canvaskit.wasm ]; then + echo " Expected CanvasKit artifacts were not generated." + exit 1 +fi + +chmod 0755 build/web/canvaskit/canvaskit.js build/web/canvaskit/canvaskit.wasm + +# DevTools server serves the extension iframe from $DEST/build/. +rm -rf "$DEST/build" +mkdir -p "$DEST/build" +cp -R build/web/. "$DEST/build/" +rm -f "$DEST/build/manifest.json" "$DEST/build/flutter_service_worker.js" + +# Ensure config.yaml exists at $DEST/ (build_and_copy does not generate it). +if [ ! -f "$DEST/config.yaml" ]; then + echo " Creating config.yaml..." + cat > "$DEST/config.yaml" << 'CFGEOF' +name: rohd +issueTracker: https://github.com/intel/rohd/issues +version: 0.0.1 +materialIconCodePoint: '0xe1c5' +requiresConnection: false +CFGEOF +fi + +# Inject a redirect as the very first |' "$DEST/build/index.html" + +echo " Extension deployed to $DEST/ (web assets in $DEST/build/)" diff --git a/tool/gh_actions/devtool/test_devtools_install.sh b/tool/gh_actions/devtool/test_devtools_install.sh new file mode 100755 index 000000000..c919b100b --- /dev/null +++ b/tool/gh_actions/devtool/test_devtools_install.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: BSD-3-Clause +# +# test_devtools_install.sh +# Smoke-test DevTools discovery of the installed ROHD DevTools extension. +# +# Usage (from repo root, after install_devtools.sh): +# bash tool/gh_actions/devtool/test_devtools_install.sh [package-root|extension-dir|github-tree-url] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +TARGET="${1:-extension/devtools}" +CLEANUP_DIR="" + +cleanup() { + if [[ -n "$CLEANUP_DIR" ]]; then + rm -rf "$CLEANUP_DIR" + fi +} +trap cleanup EXIT + +fail() { + echo " $*" >&2 + exit 1 +} + +echo "" +echo "════════════════════════════════════════════════════════════" +echo " Testing DevTools extension installation..." +echo "════════════════════════════════════════════════════════════" + +if [[ "$TARGET" =~ ^https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)(/(.*))?$ ]]; then + OWNER="${BASH_REMATCH[1]}" + REPO="${BASH_REMATCH[2]}" + BRANCH="${BASH_REMATCH[3]}" + TREE_PATH="${BASH_REMATCH[5]:-}" + CLEANUP_DIR="$(mktemp -d)" + ARCHIVE="$CLEANUP_DIR/$REPO-$BRANCH.zip" + + curl -fsSL "https://github.com/$OWNER/$REPO/archive/refs/heads/$BRANCH.zip" -o "$ARCHIVE" + unzip -q "$ARCHIVE" -d "$CLEANUP_DIR" + TARGET="$CLEANUP_DIR/$REPO-$BRANCH" + if [[ -n "$TREE_PATH" ]]; then + TARGET="$TARGET/$TREE_PATH" + fi +elif [[ "$TARGET" =~ ^https?:// ]]; then + fail "Unsupported URL. Expected a GitHub tree URL like https://github.com/intel/rohd/tree/artifacts" +elif [[ "$TARGET" != /* ]]; then + TARGET="$(pwd)/$TARGET" +fi + +if [[ ! -d "$TARGET" ]]; then + fail "Expected target directory not found: $TARGET" +fi + +(cd "$REPO_ROOT/rohd_devtools_extension" && dart run tool/test_devtools_install.dart "$TARGET") + +echo " DevTools extension installation smoke test passed." \ No newline at end of file diff --git a/tool/run_checks.sh b/tool/run_checks.sh index 5933cee30..6a0ea0080 100755 --- a/tool/run_checks.sh +++ b/tool/run_checks.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copyright (C) 2022-2023 Intel Corporation +# Copyright (C) 2022-2026 Intel Corporation # SPDX-License-Identifier: BSD-3-Clause # # run_checks.sh @@ -53,7 +53,7 @@ if which iverilog; then echo 'Icarus Verilog found!' else declare -r exit_code=${?} - declare -r iverilog_recommended_version='11' + declare -r iverilog_recommended_version='12' echo 'Icarus Verilog not found: please install Icarus Verilog'\ "(iverilog; recommended version: ${iverilog_recommended_version})!" exit ${exit_code} From dc57d210686d0356160c2a26da535d936f6e2e4f Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Jul 2026 12:20:34 -0700 Subject: [PATCH 10/11] reset to be merged after module_services_api, add significant test --- CONTRIBUTING.md | 2 +- benchmark/wave_dump_benchmark.dart | 2 +- .../answers/exercise_1_d_flip_flop.dart | 2 +- doc/tutorials/chapter_7/shift_register.dart | 2 +- .../chapter_8/answers/exercise_1_spi.dart | 2 +- .../answers/exercise_2_toycapsule_fsm.dart | 2 +- .../answers/exercise_3_pipeline.dart | 2 +- .../chapter_8/carry_save_multiplier.dart | 4 +- .../chapter_8/counter_interface.dart | 2 +- doc/tutorials/chapter_8/oven_fsm.dart | 4 +- .../rohd_vf_example/lib/rohd_vf_example.dart | 2 +- .../03-development-recommendations.md | 4 +- example/example.dart | 4 +- example/fir_filter.dart | 4 +- example/logic_array.dart | 2 +- example/oven_fsm.dart | 4 +- lib/rohd.dart | 3 +- lib/src/diagnostics/diagnostics.dart | 13 + lib/src/diagnostics/module_service.dart | 50 ++ lib/src/diagnostics/module_services.dart | 54 ++ lib/src/diagnostics/waveform_service.dart | 274 ++++++++++ lib/src/diagnostics/waveform_writer.dart | 340 ++++++++++++ lib/src/fst/fst_types.dart | 2 +- lib/src/wave_dumper.dart | 377 ++------------ test/config_test.dart | 5 +- test/counter_test.dart | 2 +- test/fst_writer_test.dart | 20 +- test/wave_dumper_test.dart | 21 +- test/waveform_service_test.dart | 485 ++++++++++++++++++ 29 files changed, 1300 insertions(+), 390 deletions(-) create mode 100644 lib/src/diagnostics/diagnostics.dart create mode 100644 lib/src/diagnostics/module_service.dart create mode 100644 lib/src/diagnostics/module_services.dart create mode 100644 lib/src/diagnostics/waveform_service.dart create mode 100644 lib/src/diagnostics/waveform_writer.dart create mode 100644 test/waveform_service_test.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6bb9116ce..16215f1e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Anyone interested in participating in ROHD is more than welcome to help! ## Code of Conduct -ROHD adopts the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1 for the code of conduct. It can be accessed [here](CODE_OF_CONDUCT.md). +ROHD adopts the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1 for the code of conduct. It can be accessed in the [ROHD Code of Conduct](CODE_OF_CONDUCT.md). ## Getting Help diff --git a/benchmark/wave_dump_benchmark.dart b/benchmark/wave_dump_benchmark.dart index 777b42eb0..1adfbdbc1 100644 --- a/benchmark/wave_dump_benchmark.dart +++ b/benchmark/wave_dump_benchmark.dart @@ -56,7 +56,7 @@ class WaveDumpBenchmark extends AsyncBenchmarkBase { _mod = _ModuleToDump(Logic(), _clk); await _mod.build(); - WaveDumper(_mod, outputPath: _vcdTemporaryPath); + WaveformService(_mod, outputPath: _vcdTemporaryPath); await Simulator.run(); diff --git a/doc/tutorials/chapter_7/answers/exercise_1_d_flip_flop.dart b/doc/tutorials/chapter_7/answers/exercise_1_d_flip_flop.dart index 15395d7ec..29eba23ae 100644 --- a/doc/tutorials/chapter_7/answers/exercise_1_d_flip_flop.dart +++ b/doc/tutorials/chapter_7/answers/exercise_1_d_flip_flop.dart @@ -60,7 +60,7 @@ Future main() async { unawaited(Simulator.run()); - WaveDumper(dff, + WaveformService(dff, outputPath: 'doc/tutorials/chapter_7/answers/d_flip_flop.vcd'); printFlop('Before'); diff --git a/doc/tutorials/chapter_7/shift_register.dart b/doc/tutorials/chapter_7/shift_register.dart index 1dc98c4f1..2324a860b 100644 --- a/doc/tutorials/chapter_7/shift_register.dart +++ b/doc/tutorials/chapter_7/shift_register.dart @@ -69,7 +69,7 @@ void main() async { // kick-off the simulator, but we don't want to wait unawaited(Simulator.run()); - WaveDumper(shiftReg, + WaveformService(shiftReg, outputPath: 'doc/tutorials/chapter_7/shift_register.vcd'); printFlop('Before'); diff --git a/doc/tutorials/chapter_8/answers/exercise_1_spi.dart b/doc/tutorials/chapter_8/answers/exercise_1_spi.dart index d8315156d..c3dcb8751 100644 --- a/doc/tutorials/chapter_8/answers/exercise_1_spi.dart +++ b/doc/tutorials/chapter_8/answers/exercise_1_spi.dart @@ -163,7 +163,7 @@ void main() async { Simulator.setMaxSimTime(100); unawaited(Simulator.run()); - WaveDumper(peri, outputPath: 'doc/tutorials/chapter_8/spi-new.vcd'); + WaveformService(peri, outputPath: 'doc/tutorials/chapter_8/spi-new.vcd'); await drive(LogicValue.ofString('01010101')); } diff --git a/doc/tutorials/chapter_8/answers/exercise_2_toycapsule_fsm.dart b/doc/tutorials/chapter_8/answers/exercise_2_toycapsule_fsm.dart index 6912f1313..044e24cc6 100644 --- a/doc/tutorials/chapter_8/answers/exercise_2_toycapsule_fsm.dart +++ b/doc/tutorials/chapter_8/answers/exercise_2_toycapsule_fsm.dart @@ -55,7 +55,7 @@ Future main(List args) async { reset.inject(1); - WaveDumper(toyCap, outputPath: 'toyCapsuleFSM.vcd'); + WaveformService(toyCap, outputPath: 'toyCapsuleFSM.vcd'); Simulator.setMaxSimTime(100); Simulator.registerAction(25, () { diff --git a/doc/tutorials/chapter_8/answers/exercise_3_pipeline.dart b/doc/tutorials/chapter_8/answers/exercise_3_pipeline.dart index ef93701b3..24315f0ee 100644 --- a/doc/tutorials/chapter_8/answers/exercise_3_pipeline.dart +++ b/doc/tutorials/chapter_8/answers/exercise_3_pipeline.dart @@ -41,7 +41,7 @@ void main(List args) async { Simulator.registerAction(10, () => reset.put(0)); - WaveDumper(pipe, outputPath: 'answer_1.vcd'); + WaveformService(pipe, outputPath: 'answer_1.vcd'); Simulator.registerAction(50, () async { // stage 4 / result: 30 + (30 * 3) = 120 diff --git a/doc/tutorials/chapter_8/carry_save_multiplier.dart b/doc/tutorials/chapter_8/carry_save_multiplier.dart index 82b9521da..73b37f1fb 100644 --- a/doc/tutorials/chapter_8/carry_save_multiplier.dart +++ b/doc/tutorials/chapter_8/carry_save_multiplier.dart @@ -107,8 +107,8 @@ void main() async { b.inject(14); reset.inject(1); - // Attach a waveform dumper so we can see what happens. - WaveDumper(csm, outputPath: 'csm.vcd'); + // Attach a waveform service so we can see what happens. + WaveformService(csm, outputPath: 'csm.vcd'); Simulator.registerAction(10, () { reset.inject(0); diff --git a/doc/tutorials/chapter_8/counter_interface.dart b/doc/tutorials/chapter_8/counter_interface.dart index 41a49eb0e..2c4e60317 100644 --- a/doc/tutorials/chapter_8/counter_interface.dart +++ b/doc/tutorials/chapter_8/counter_interface.dart @@ -65,7 +65,7 @@ Future main() async { print(counter.generateSynth()); - WaveDumper(counter, + WaveformService(counter, outputPath: 'doc/tutorials/chapter_8/counter_interface.vcd'); Simulator.registerAction(25, () { intf.en.put(1); diff --git a/doc/tutorials/chapter_8/oven_fsm.dart b/doc/tutorials/chapter_8/oven_fsm.dart index 172d83006..f23ed7d63 100644 --- a/doc/tutorials/chapter_8/oven_fsm.dart +++ b/doc/tutorials/chapter_8/oven_fsm.dart @@ -190,9 +190,9 @@ Future main({bool noPrint = false}) async { // Let's start off with asserting reset to Oven. reset.inject(1); - // Attach a waveform dumper so we can see what happens. + // Attach a waveform service so we can see what happens. if (!noPrint) { - WaveDumper(oven, outputPath: 'doc/tutorials/chapter_8/oven.vcd'); + WaveformService(oven, outputPath: 'doc/tutorials/chapter_8/oven.vcd'); } if (!noPrint) { diff --git a/doc/tutorials/chapter_9/rohd_vf_example/lib/rohd_vf_example.dart b/doc/tutorials/chapter_9/rohd_vf_example/lib/rohd_vf_example.dart index 4b1ef9c34..77d2a5a32 100644 --- a/doc/tutorials/chapter_9/rohd_vf_example/lib/rohd_vf_example.dart +++ b/doc/tutorials/chapter_9/rohd_vf_example/lib/rohd_vf_example.dart @@ -315,7 +315,7 @@ Future main({Level loggerLevel = Level.FINER}) async { await tb.counter.build(); // dump wave here - WaveDumper(tb.counter); + WaveformService(tb.counter); // Set a maximum simulation time so it doesn't run forever Simulator.setMaxSimTime(300); diff --git a/doc/user_guide/_get-started/03-development-recommendations.md b/doc/user_guide/_get-started/03-development-recommendations.md index d224e54df..6ffb5ab7b 100644 --- a/doc/user_guide/_get-started/03-development-recommendations.md +++ b/doc/user_guide/_get-started/03-development-recommendations.md @@ -10,9 +10,9 @@ toc: true - The [ROHD Cosimulation](https://github.com/intel/rohd-cosim) package allows you to cosimulate the ROHD simulator with a variety of SystemVerilog simulators. - The [ROHD Hardware Component Library](https://github.com/intel/rohd-vf) provides a set of reusable and configurable components for design and verification. - Visual Studio Code (vscode) is a great, free IDE with excellent support for Dart. It works well on all platforms, including native Windows or Windows Subsystem for Linux (WSL) which allows you to run a native Linux kernel (e.g. Ubuntu) within Windows. You can also use vscode to develop on a remote machine with the Remote SSH extension. - - vscode: + - vscode: - WSL: - - Remote SSH: + - Remote SSH: - Dart extension for vscode: Head over to the [user guide]({{ site.baseurl }}{% link _docs/A01-sample-example.md %}) to learn more about how to use ROHD. diff --git a/example/example.dart b/example/example.dart index 2ddbfc738..d41fc51a6 100644 --- a/example/example.dart +++ b/example/example.dart @@ -68,9 +68,9 @@ Future main({bool noPrint = false}) async { // Now let's try simulating! - // Attach a waveform dumper so we can see what happens. + // Attach a waveform service so we can see what happens. if (!noPrint) { - WaveDumper(counter); + WaveformService(counter); } // Let's also print a message every time the value on the counter changes, diff --git a/example/fir_filter.dart b/example/fir_filter.dart index 1f17f0d3d..1490b0400 100644 --- a/example/fir_filter.dart +++ b/example/fir_filter.dart @@ -106,9 +106,9 @@ Future main({bool noPrint = false}) async { // Now let's try simulating! - // Attach a waveform dumper. + // Attach a waveform service. if (!noPrint) { - WaveDumper(firFilter); + WaveformService(firFilter); } // Let's set the initial setting. diff --git a/example/logic_array.dart b/example/logic_array.dart index f772c4929..0342b778b 100644 --- a/example/logic_array.dart +++ b/example/logic_array.dart @@ -65,7 +65,7 @@ Future main({bool noPrint = false}) async { // Simulate the module if (!noPrint) { - WaveDumper(logicArrayExample); + WaveformService(logicArrayExample); } // Set the input values diff --git a/example/oven_fsm.dart b/example/oven_fsm.dart index 2788baa55..c8ba3fb76 100644 --- a/example/oven_fsm.dart +++ b/example/oven_fsm.dart @@ -223,9 +223,9 @@ Future main({bool noPrint = false}) async { // Set a maximum time for the simulation so it doesn't keep running forever. Simulator.setMaxSimTime(300); - // Attach a waveform dumper so we can see what happens. + // Attach a waveform service so we can see what happens. if (!noPrint) { - WaveDumper(oven, outputPath: 'oven.vcd'); + WaveformService(oven, outputPath: 'oven.vcd'); } // Kick off the simulation. diff --git a/lib/rohd.dart b/lib/rohd.dart index b9b3b3929..6428d3463 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/diagnostics.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; diff --git a/lib/src/diagnostics/diagnostics.dart b/lib/src/diagnostics/diagnostics.dart new file mode 100644 index 000000000..150721f21 --- /dev/null +++ b/lib/src/diagnostics/diagnostics.dart @@ -0,0 +1,13 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// diagnostics.dart +// Barrel export for the diagnostics library. +// +// 2026 July 16 +// Author: Desmond Kirkpatrick + +export 'module_service.dart'; +export 'module_services.dart'; +export 'waveform_service.dart'; +export 'waveform_writer.dart'; diff --git a/lib/src/diagnostics/module_service.dart b/lib/src/diagnostics/module_service.dart new file mode 100644 index 000000000..d033775fc --- /dev/null +++ b/lib/src/diagnostics/module_service.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_service.dart +// Common base types shared by all module-scoped services. +// +// 2026 June 23 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// The common contract implemented by every module-scoped service that +/// registers with [ModuleServices]. +abstract interface class ModuleService { + /// The top-level [Module] this service operates on. + Module get module; + + /// A JSON-serialisable summary of this service. + Map toJson(); +} + +/// A [ModuleService] that emits output to one or more files. +abstract class OutputService implements ModuleService { + /// The default location written by [write]. + String? get outputPath; + + /// Whether [write] emits one file per module definition (`true`) or a single + /// combined file (`false`). + bool get multiFile; + + /// Writes this service's output to [path], or to [outputPath] when [path] is + /// omitted. + void write([String? path]); +} + +/// An [OutputService] that generates source-code text, keyed per module +/// definition. +abstract class CodeGenService extends OutputService { + /// The combined single-file generated output (including any header). + String get output; + + /// The generated output keyed by module definition name + /// ([Module.definitionName]). + Map get contentsByDefinitionName; + + /// The generated output for a single module [definitionName], or `null` when + /// that definition was not generated. + String? moduleOutput(String definitionName) => + contentsByDefinitionName[definitionName]; +} diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart new file mode 100644 index 000000000..72e3a2245 --- /dev/null +++ b/lib/src/diagnostics/module_services.dart @@ -0,0 +1,54 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services.dart +// Slim, type-keyed registry of module-scoped services for DevTools and other +// inspection tools. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/diagnostics/inspector_service.dart'; + +/// A slim, type-keyed registry of [ModuleService]s. +class ModuleServices { + ModuleServices._(); + + /// The singleton instance. + static final ModuleServices instance = ModuleServices._(); + + Module? _rootModule; + + /// The most recently built top-level [Module]. + Module? get rootModule => _rootModule; + + set rootModule(Module? value) { + _rootModule = value; + ModuleTree.rootModuleInstance = value; + } + + /// Returns the module hierarchy as a JSON string. + String get hierarchyJSON => ModuleTree.instance.hierarchyJSON; + + final Map _services = {}; + + /// Registers [service] under the type argument [T]. + void register(T service) { + _services[T] = service; + } + + /// Returns the registered service of type [T], or `null` if none. + T? lookup() => _services[T] as T?; + + /// Removes the registered service of type [T], if any. + void unregister() { + _services.remove(T); + } + + /// Resets all services. Intended for test teardown. + void reset() { + rootModule = null; + _services.clear(); + } +} diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart new file mode 100644 index 000000000..7669b0479 --- /dev/null +++ b/lib/src/diagnostics/waveform_service.dart @@ -0,0 +1,274 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_service.dart +// Base waveform service: capture module signal changes to waveform writers. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:collection'; + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; +import 'package:rohd/src/utilities/uniquifier.dart'; + +/// A waveform capture service that writes signal changes to a file. +class WaveformService implements ModuleService { + /// The most recently registered [WaveformService], or `null`. + static WaveformService? current; + + /// The top-level [Module] being captured. + @override + final Module module; + + /// Path of the output waveform file. + final String outputPath; + + /// Output format. + final WaveOutputFormat format; + + /// Optional predicate that determines whether a given [Logic] signal is + /// captured. + final bool Function(Logic signal)? signalFilter; + + /// VCD timescale string, e.g. `'1ps'`, `'1ns'`. + final String timescale; + + /// Simulation time at which recording begins. + final int? startTime; + + /// Simulation time at which recording ends. + final int? stopTime; + + /// Number of characters accumulated in the VCD write buffer before it is + /// flushed to disk. + final int flushBufferSize; + + /// What to do when the output file already exists. + final OverwritePolicy overwritePolicy; + + /// Whether to register this service with [ModuleServices] for inspection. + final bool register; + + /// Whether to enable DevTools streaming. + /// + /// The base [WaveformService] stores this flag but takes no action on it. + /// Downstream DevTools integrations can subclass or observe the hooks below. + final bool enableDevToolsStreaming; + + /// The FST writer configuration (only used when [format] is + /// [WaveOutputFormat.fst]). + final FstWriterConfig? fstConfig; + + late final WaveformWriter _writer; + + /// Maps each captured [Logic] to its writer-specific signal handle. + final Map _signalHandles = {}; + + /// Signals that changed during the current simulation timestamp. + final Set _changedThisTimestamp = HashSet(); + + /// The timestamp currently being accumulated. + int _currentDumpingTimestamp = Simulator.time; + + /// Creates a [WaveformService] for [module]. + /// + /// [module] must be built before construction. + WaveformService( + this.module, { + this.outputPath = 'waves.vcd', + this.format = WaveOutputFormat.vcd, + this.signalFilter, + this.timescale = '1ps', + this.startTime, + this.stopTime, + this.flushBufferSize = 100000, + this.overwritePolicy = OverwritePolicy.overwrite, + this.register = true, + this.enableDevToolsStreaming = false, + this.fstConfig, + }) { + if (!module.hasBuilt) { + throw Exception( + 'Module must be built before creating WaveformService. ' + 'Call build() first.', + ); + } + + _writer = _createWriter(); + _collectSignals(module); + _writer.finishDeclarations( + _signalHandles.entries.map( + (entry) => WaveformInitialValue(entry.value, _binaryValue(entry.key)), + ), + timestamp: Simulator.time, + ); + + Simulator.preTick.listen((_) { + if (Simulator.time != _currentDumpingTimestamp) { + if (_changedThisTimestamp.isNotEmpty) { + _captureTimestamp(_currentDumpingTimestamp); + } + _currentDumpingTimestamp = Simulator.time; + } + }); + + Simulator.registerEndOfSimulationAction(() async { + _captureTimestamp(Simulator.time); + await _terminate(); + onSimulationEnd(); + }); + + if (register) { + current = this; + ModuleServices.instance.register(this); + } + } + + /// The concrete output writer used by this service. + @protected + WaveformWriter get writer => _writer; + + /// Called once for each [Logic] signal that passes [signalFilter]. + @protected + void onSignalCollected(Logic signal) {} + + /// Called for every value-change event on [signal] at [timestamp]. + @protected + void onValueChange(Logic signal, int timestamp) {} + + /// Called once per simulation timestamp that contains at least one change. + @protected + void onTimestampCapture(int timestamp, Set changed) {} + + /// Called after the final timestamp has been written and the file is closed. + @protected + void onSimulationEnd() {} + + WaveformWriter _createWriter() { + switch (format) { + case WaveOutputFormat.vcd: + return VcdWaveformWriter( + outputPath, + timescale: timescale, + flushBufferSize: flushBufferSize, + overwritePolicy: overwritePolicy, + ); + case WaveOutputFormat.fst: + return FstWaveformWriter( + outputPath, + config: fstConfig ?? const FstWriterConfig(), + ); + } + } + + bool _collectSignals(Module module) { + final moduleSignalUniquifier = Uniquifier(); + var hasContents = false; + + _writer.pushScope(module.uniqueInstanceName); + + for (final sig in module.signals) { + if (sig is Const) { + continue; + } + if (signalFilter != null && !signalFilter!(sig)) { + continue; + } + + hasContents = true; + final baseName = Sanitizer.sanitizeSV(sig.name); + final signalName = moduleSignalUniquifier.getUniqueName( + initialName: baseName, + reserved: sig.isPort, + ); + final handle = _writer.declareSignal( + signalName, + sig.width, + direction: _directionOf(sig), + ); + _signalHandles[sig] = handle; + onSignalCollected(sig); + + sig.changed.listen((_) { + _changedThisTimestamp.add(sig); + }); + } + + for (final subModule in module.subModules) { + if (subModule is InlineSystemVerilog) { + continue; + } + hasContents = _collectSignals(subModule) || hasContents; + } + + _writer.popScope(); + return hasContents; + } + + WaveformSignalDirection _directionOf(Logic signal) { + if (!signal.isPort) { + return WaveformSignalDirection.implicit; + } + return signal.isInput + ? WaveformSignalDirection.input + : WaveformSignalDirection.output; + } + + bool _isInRecordingWindow(int timestamp) { + if (startTime != null && timestamp < startTime!) { + return false; + } + if (stopTime != null && timestamp > stopTime!) { + return false; + } + return true; + } + + void _captureTimestamp(int timestamp) { + if (!_isInRecordingWindow(timestamp)) { + _changedThisTimestamp.clear(); + return; + } + + final snapshot = Set.of(_changedThisTimestamp); + final changes = [ + for (final sig in snapshot) + WaveformValueChange(_signalHandles[sig]!, _binaryValue(sig)), + ]; + + if (changes.isNotEmpty) { + _writer.emitValueChanges(timestamp, changes); + } + + for (final sig in snapshot) { + onValueChange(sig, timestamp); + } + _changedThisTimestamp.clear(); + + if (snapshot.isNotEmpty) { + onTimestampCapture(timestamp, snapshot); + } + } + + String _binaryValue(Logic signal) => signal.value.reversed + .toList() + .map((e) => e.toString(includeWidth: false)) + .join(); + + Future _terminate() => _writer.close(); + + /// Returns a JSON-serialisable summary of this service. + @override + Map toJson() => { + 'outputPath': outputPath, + 'format': format.name, + 'signalCount': _signalHandles.length, + 'timescale': timescale, + if (startTime != null) 'startTime': startTime, + if (stopTime != null) 'stopTime': stopTime, + 'writer': _writer.toJson(), + }; +} diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart new file mode 100644 index 000000000..abd21ab9f --- /dev/null +++ b/lib/src/diagnostics/waveform_writer.dart @@ -0,0 +1,340 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_writer.dart +// Common output backend API for waveform capture services. +// +// 2026 July 17 +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/timestamper.dart'; + +/// The output format for waveform capture. +enum WaveOutputFormat { + /// Value Change Dump, the classic text-based waveform format. + vcd, + + /// Fast Signal Trace, a compact binary format. + fst, +} + +/// Policy applied when the output file already exists at construction time. +enum OverwritePolicy { + /// Silently overwrite any existing file. + overwrite, + + /// Throw a [FileSystemException] if the file already exists. + failIfExists, +} + +/// Direction metadata for a signal emitted into a waveform file. +enum WaveformSignalDirection { + /// Input port. + input, + + /// Output port. + output, + + /// Internal or implicit signal. + implicit, +} + +/// Initial value for a declared waveform signal. +class WaveformInitialValue { + /// The writer-specific handle returned by [WaveformWriter.declareSignal]. + final Object handle; + + /// The MSB-first binary value string. + final String value; + + /// Creates an initial value entry. + const WaveformInitialValue(this.handle, this.value); +} + +/// Timestamped value change for a declared waveform signal. +class WaveformValueChange extends WaveformInitialValue { + /// Creates a value-change entry. + const WaveformValueChange(super.handle, super.value); +} + +/// Common backend contract for waveform file formats. +abstract class WaveformWriter { + /// The file format emitted by this writer. + WaveOutputFormat get format; + + /// Pushes a scope onto the declaration hierarchy. + void pushScope(String name); + + /// Pops the current declaration scope. + void popScope(); + + /// Declares a signal and returns a writer-specific handle. + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }); + + /// Finishes declarations and emits initial values. + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }); + + /// Emits all value changes for [timestamp]. + void emitValueChanges(int timestamp, Iterable changes); + + /// Flushes and closes the waveform output. + Future close(); + + /// Returns a JSON-serialisable summary of writer state. + Map toJson(); +} + +/// VCD implementation of [WaveformWriter]. +class VcdWaveformWriter implements WaveformWriter { + /// Creates a VCD writer at [outputPath]. + VcdWaveformWriter( + this.outputPath, { + this.timescale = '1ps', + this.flushBufferSize = 100000, + this.overwritePolicy = OverwritePolicy.overwrite, + }) { + if (overwritePolicy == OverwritePolicy.failIfExists) { + final existingFile = File(outputPath); + if (existingFile.existsSync()) { + throw FileSystemException( + 'Waveform output file already exists and overwritePolicy is ' + 'failIfExists.', + outputPath, + ); + } + } + + _outputFile = File(outputPath)..createSync(recursive: true); + _outFileSink = _outputFile.openWrite(); + _writeHeader(); + } + + /// The output file path. + final String outputPath; + + /// VCD timescale string, e.g. `'1ps'`, `'1ns'`. + final String timescale; + + /// Number of characters accumulated before flushing to disk. + final int flushBufferSize; + + /// Existing-file policy. + final OverwritePolicy overwritePolicy; + + late final File _outputFile; + late final IOSink _outFileSink; + final StringBuffer _fileBuffer = StringBuffer(); + final StringBuffer _scopeBuffer = StringBuffer(); + final Map _handleWidths = {}; + var _signalMarkerIdx = 0; + var _indent = 0; + var _closed = false; + + @override + WaveOutputFormat get format => WaveOutputFormat.vcd; + + @override + void pushScope(String name) { + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$scope module $name \$end\n'); + _indent++; + } + + @override + void popScope() { + _indent--; + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$upscope \$end\n'); + } + + @override + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }) { + final marker = 's${_signalMarkerIdx++}'; + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$var wire $width $marker $name \$end\n'); + _handleWidths[marker] = width; + return marker; + } + + @override + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }) { + _writeToBuffer(_scopeBuffer.toString()); + _writeToBuffer('\$enddefinitions \$end\n'); + _writeToBuffer('\$dumpvars\n'); + for (final initialValue in initialValues) { + _writeValueUpdate(initialValue.handle, initialValue.value); + } + _writeToBuffer('\$end\n'); + } + + @override + void emitValueChanges( + int timestamp, + Iterable changes, + ) { + _writeToBuffer('#$timestamp\n'); + for (final change in changes) { + _writeValueUpdate(change.handle, change.value); + } + } + + @override + Future close() async { + if (_closed) { + return; + } + _closed = true; + _flushBuffer(); + await _outFileSink.flush(); + await _outFileSink.close(); + } + + @override + Map toJson() => { + 'format': format.name, + 'signalCount': _handleWidths.length, + 'timescale': timescale, + }; + + void _writeHeader() { + final header = ''' +\$date + ${Timestamper.stamp()} +\$end +\$version + ROHD v${Config.version} +\$end +\$comment + Generated by ROHD - www.github.com/intel/rohd +\$end +\$timescale $timescale \$end +'''; + _writeToBuffer(header); + } + + void _writeValueUpdate(Object handle, String value) { + final width = _handleWidths[handle]; + if (width == null) { + throw StateError('Unknown VCD signal handle: $handle'); + } + final updateValue = width > 1 ? 'b$value ' : value; + _writeToBuffer('$updateValue$handle\n'); + } + + void _writeToBuffer(String contents) { + _fileBuffer.write(contents); + if (_fileBuffer.length > flushBufferSize) { + _flushBuffer(); + } + } + + void _flushBuffer() { + _outFileSink.write(_fileBuffer.toString()); + _fileBuffer.clear(); + } +} + +/// FST implementation of [WaveformWriter]. +class FstWaveformWriter implements WaveformWriter { + /// Creates an FST writer at [outputPath]. + FstWaveformWriter( + String outputPath, { + FstWriterConfig config = const FstWriterConfig(), + }) : writer = FstWriter(outputPath, config: config); + + /// The low-level FST binary writer. + final FstWriter writer; + + @override + WaveOutputFormat get format => WaveOutputFormat.fst; + + @override + void pushScope(String name) { + writer.pushScope(name); + } + + @override + void popScope() { + writer.popScope(); + } + + @override + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }) => + writer.declareSignal( + name, + width, + direction: _fstDirection(direction), + ); + + @override + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }) { + writer.writeHeader(); + for (final initialValue in initialValues) { + writer.emitValueChange( + timestamp, + initialValue.handle as FstSignalHandle, + initialValue.value, + ); + } + } + + @override + void emitValueChanges( + int timestamp, + Iterable changes, + ) { + for (final change in changes) { + writer.emitValueChange( + timestamp, + change.handle as FstSignalHandle, + change.value, + ); + } + } + + @override + Future close() async { + writer.finish(); + } + + @override + Map toJson() => { + 'format': format.name, + }; + + FstVarDirection _fstDirection(WaveformSignalDirection direction) { + switch (direction) { + case WaveformSignalDirection.input: + return FstVarDirection.input; + case WaveformSignalDirection.output: + return FstVarDirection.output; + case WaveformSignalDirection.implicit: + return FstVarDirection.implicit; + } + } +} diff --git a/lib/src/fst/fst_types.dart b/lib/src/fst/fst_types.dart index b39b9ef5c..13e829c28 100644 --- a/lib/src/fst/fst_types.dart +++ b/lib/src/fst/fst_types.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2026 Intel Corporation +// Copyright (C) 2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // fst_types.dart diff --git a/lib/src/wave_dumper.dart b/lib/src/wave_dumper.dart index 1e426f02a..80f1b2fb6 100644 --- a/lib/src/wave_dumper.dart +++ b/lib/src/wave_dumper.dart @@ -2,385 +2,66 @@ // SPDX-License-Identifier: BSD-3-Clause // // wave_dumper.dart -// Waveform dumper for a given module hierarchy, dumps to ".vcd" or ".fst" file. +// Deprecated waveform dumper; use WaveformService instead. // // 2021 May 7 // Author: Max Korbel // 2026 February - Added FST format support // Author: Desmond Kirkpatrick -import 'dart:collection'; -import 'dart:io'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/utilities/config.dart'; -import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/timestamper.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// Waveform output format. enum WaveFormat { - /// VCD (Value Change Dump) — IEEE 1364 standard text format. + /// VCD (Value Change Dump) text format. vcd, - /// FST (Fast Signal Trace) — GTKWave binary format. - /// - /// FST files are compressed, support random access, and are compatible - /// with GTKWave, Surfer, and the wellen reader. + /// FST (Fast Signal Trace) binary format. fst, } -/// A waveform dumper for simulations. -/// -/// Outputs to VCD or FST format at [outputPath]. [module] must be built prior -/// to attaching the [WaveDumper]. -/// -/// The waves will only dump to the file periodically and then once the -/// simulation has completed. -/// +/// Deprecated: use [WaveformService] instead. /// -/// To output FST (compressed binary) instead of VCD (text): -/// ```dart -/// WaveDumper(module, outputPath: 'waves.fst', format: WaveFormat.fst); -/// ``` +/// [WaveDumper] is a compatibility wrapper around [WaveformService]. +@Deprecated('Use WaveformService instead') class WaveDumper { + /// The underlying [WaveformService]. + final WaveformService _service; + /// The [Module] being dumped. - final Module module; + Module get module => _service.module; /// The output filepath of the generated waveforms. - final String outputPath; + String get outputPath => _service.outputPath; - /// The waveform output format (VCD or FST). - final WaveFormat format; + /// The waveform output format. + WaveFormat get format => + _service.format == WaveOutputFormat.fst ? WaveFormat.fst : WaveFormat.vcd; /// The FST writer configuration (only used when [format] is /// [WaveFormat.fst]). final FstWriterConfig? fstConfig; - /// The file to write dumped output waveform to (VCD only). - File? _outputFile; - - /// A sink to write contents into [_outputFile] (VCD only). - IOSink? _outFileSink; - - /// A buffer for contents before writing to the file sink (VCD only). - final StringBuffer _fileBuffer = StringBuffer(); - - /// A counter for tracking signal names in the VCD file. - int _signalMarkerIdx = 0; - - /// Stores the mapping from [Logic] to signal marker in the VCD file. - final Map _signalToMarkerMap = {}; - - /// Stores the mapping from [Logic] to FST signal handle (FST only). - final Map _signalToFstHandle = {}; - - /// The FST writer instance (FST only). - FstWriter? _fstWriter; - - /// A set of all [Logic]s that have changed in this timestamp so far. - /// - /// This spans across multiple inject or changed events if they are in the - /// same timestamp of the [Simulator]. - final Set _changedLogicsThisTimestamp = HashSet(); - - /// The timestamp which is currently being collected for a dump. - /// - /// When the [Simulator] time progresses beyond this, it will dump all the - /// signals that have changed up until that point at this saved time value. - int _currentDumpingTimestamp = Simulator.time; - /// Attaches a [WaveDumper] to record all signal changes in a simulation of /// [module] in a waveform file at [outputPath]. /// - /// The output [format] defaults to [WaveFormat.vcd] for VCD text files. - /// Set to [WaveFormat.fst] for compressed FST binary files. - /// + /// [module] must be built prior to construction. + @Deprecated('Use WaveformService instead') WaveDumper( - this.module, { - this.outputPath = 'waves.vcd', - this.format = WaveFormat.vcd, + Module module, { + String outputPath = 'waves.vcd', + WaveFormat format = WaveFormat.vcd, this.fstConfig, - }) { - if (!module.hasBuilt) { - throw Exception( - 'Module must be built before passed to dumper. Call build() first.'); - } - - if (format == WaveFormat.fst) { - _initFst(); - } else { - _initVcd(); - } - - Simulator.preTick.listen((args) { - if (Simulator.time != _currentDumpingTimestamp) { - if (_changedLogicsThisTimestamp.isNotEmpty) { - // no need to write blank timestamps - _captureTimestamp(_currentDumpingTimestamp); - } - _currentDumpingTimestamp = Simulator.time; - } - }); - - Simulator.registerEndOfSimulationAction(() async { - _captureTimestamp(Simulator.time); - - await _terminate(); - }); - } - - /// Number of characters in the buffer after which it will - /// write contents to the output file. - static const _fileBufferLimit = 100000; - - // ─────────────── VCD initialization ─────────────── - - /// Initializes VCD output. - void _initVcd() { - _outputFile = File(outputPath)..createSync(recursive: true); - _outFileSink = _outputFile!.openWrite(); - _collectAllSignals(); - _writeVcdHeader(); - _writeVcdScope(); - } - - // ─────────────── FST initialization ─────────────── - - /// Initializes FST output. - void _initFst() { - _fstWriter = - FstWriter(outputPath, config: fstConfig ?? const FstWriterConfig()); - - // Walk module hierarchy and declare signals - _collectAllSignalsFst(module); - - // Write header after all signals declared - _fstWriter!.writeHeader(); - } - - /// Collects signals from the module hierarchy and declares them in the FST - /// writer. - void _collectAllSignalsFst(Module m) { - _fstWriter!.pushScope(m.uniqueInstanceName); - var hasSignals = false; - - final moduleSignalUniquifier = Uniquifier(); - - for (final sig in m.signals) { - if (sig is Const) { - continue; - } - - hasSignals = true; - final baseName = Sanitizer.sanitizeSV(sig.name); - final signalName = moduleSignalUniquifier.getUniqueName( - initialName: baseName, reserved: sig.isPort); - - final handle = _fstWriter!.declareSignal( - signalName, - sig.width, - direction: sig.isPort - ? (sig.isInput ? FstVarDirection.input : FstVarDirection.output) - : FstVarDirection.implicit, - ); - _signalToFstHandle[sig] = handle; - - sig.changed.listen((args) { - _changedLogicsThisTimestamp.add(sig); - }); - } - - for (final subm in m.subModules) { - if (subm is InlineSystemVerilog) { - continue; - } - _collectAllSignalsFst(subm); - } - - // Only pop scope if we had content (matching VCD empty-scope behavior) - if (!hasSignals && - m.subModules.where((s) => s is! InlineSystemVerilog).isEmpty) { - // empty scope — we still need to pop what we pushed - } - _fstWriter!.popScope(); - } - - // ─────────────── Shared methods ─────────────── - - /// Buffers [contents] to be written to the VCD output file. - void _writeToBuffer(String contents) { - _fileBuffer.write(contents); - - if (_fileBuffer.length > _fileBufferLimit) { - _writeToFile(); - } - } - - /// Writes all pending items in the [_fileBuffer] to the VCD file. - void _writeToFile() { - _outFileSink?.write(_fileBuffer.toString()); - _fileBuffer.clear(); - } - - /// Terminates the waveform dumping, including closing the file. - Future _terminate() async { - if (format == WaveFormat.fst) { - // For FST: flush any remaining changes and finalize - _fstWriter?.finish(); - } else { - // For VCD: flush buffer and close file - _writeToFile(); - await _outFileSink?.flush(); - await _outFileSink?.close(); - } - } - - /// Registers all signal value changes to write updates to the dumped VCD. - void _collectAllSignals() { - final modulesToParse = [module]; - for (var i = 0; i < modulesToParse.length; i++) { - final m = modulesToParse[i]; - for (final sig in m.signals) { - if (sig is Const) { - // constant values are "boring" to inspect - continue; - } - - _signalToMarkerMap[sig] = 's${_signalMarkerIdx++}'; - sig.changed.listen((args) { - _changedLogicsThisTimestamp.add(sig); - }); - } - - for (final subm in m.subModules) { - if (subm is InlineSystemVerilog) { - // the InlineSystemVerilog modules are "boring" to inspect - continue; - } - modulesToParse.add(subm); - } - } - } - - // ─────────────── VCD-specific methods ─────────────── - - /// Writes the top header for the VCD file. - void _writeVcdHeader() { - final dateString = Timestamper.stamp(); - const timescale = '1ps'; - final header = ''' -\$date - $dateString -\$end -\$version - ROHD v${Config.version} -\$end -\$comment - Generated by ROHD - www.github.com/intel/rohd -\$end -\$timescale $timescale \$end -'''; - _writeToBuffer(header); - } - - /// Writes the scope of the VCD, including signal and hierarchy declarations, - /// as well as initial values. - void _writeVcdScope() { - var scopeString = _computeScopeString(module); - scopeString += '\$enddefinitions \$end\n'; - scopeString += '\$dumpvars\n'; - _writeToBuffer(scopeString); - _signalToMarkerMap.keys.forEach(_writeSignalValueUpdate); - - _writeToBuffer('\$end\n'); - } - - /// Generates the top of the scope string (signal and hierarchy definitions). - String _computeScopeString(Module m, {int indent = 0}) { - final moduleSignalUniquifier = Uniquifier(); - final padding = List.filled(indent, ' ').join(); - var scopeString = '$padding\$scope module ${m.uniqueInstanceName} \$end\n'; - final innerScopeString = StringBuffer(); - for (final sig in m.signals) { - if (!_signalToMarkerMap.containsKey(sig)) { - continue; - } - - final width = sig.width; - final marker = _signalToMarkerMap[sig]; - final baseName = Sanitizer.sanitizeSV(sig.name); - final signalName = moduleSignalUniquifier.getUniqueName( - initialName: baseName, reserved: sig.isPort); - innerScopeString - .write(' $padding\$var wire $width $marker $signalName \$end\n'); - } - - for (final subModule in m.subModules) { - innerScopeString - .write(_computeScopeString(subModule, indent: indent + 1)); - } - if (innerScopeString.isEmpty) { - // no need to dump empty scopes - return ''; - } - scopeString += innerScopeString.toString(); - scopeString += '$padding\$upscope \$end\n'; - return scopeString; - } - - // ─────────────── Timestamp capture ─────────────── - - /// Captures all signal changes at the current timestamp. - void _captureTimestamp(int timestamp) { - if (format == WaveFormat.fst) { - _captureTimestampFst(timestamp); - } else { - _captureTimestampVcd(timestamp); - } - } - - /// Captures a VCD timestamp: writes the timestamp marker and changed values. - void _captureTimestampVcd(int timestamp) { - final timestampString = '#$timestamp\n'; - _writeToBuffer(timestampString); - - _changedLogicsThisTimestamp - ..forEach(_writeSignalValueUpdate) - ..clear(); - } - - /// Captures an FST timestamp: emits value changes for all changed signals. - void _captureTimestampFst(int timestamp) { - for (final sig in _changedLogicsThisTimestamp) { - final handle = _signalToFstHandle[sig]; - if (handle == null) { - continue; - } - - final binaryValue = sig.value.reversed - .toList() - .map((e) => e.toString(includeWidth: false)) - .join(); - _fstWriter!.emitValueChange(timestamp, handle, binaryValue); - } - _changedLogicsThisTimestamp.clear(); - } - - /// Writes the current value of [signal] to the VCD. - void _writeSignalValueUpdate(Logic signal) { - final binaryValue = signal.value.reversed - .toList() - .map((e) => e.toString(includeWidth: false)) - .join(); - final updateValue = signal.width > 1 - ? 'b$binaryValue ' - : signal.value.toString(includeWidth: false); - final marker = _signalToMarkerMap[signal]; - final updateString = '$updateValue$marker\n'; - _writeToBuffer(updateString); - } + }) : _service = WaveformService( + module, + outputPath: outputPath, + format: format == WaveFormat.fst + ? WaveOutputFormat.fst + : WaveOutputFormat.vcd, + fstConfig: fstConfig, + ); } -/// Deprecated: use [WaveDumper] instead. -@Deprecated('Use WaveDumper instead') +/// Deprecated: use [WaveformService] instead. +@Deprecated('Use WaveformService instead') typedef Dumper = WaveDumper; diff --git a/test/config_test.dart b/test/config_test.dart index 28cd2e7d8..33137aaff 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -52,8 +52,9 @@ void main() async { }); if (!kIsWeb) { - test('should contains ROHD version number when wavedumper is generated.', - () async { + test( + 'should contains ROHD version number when ' + 'waveform service is generated.', () async { const version = Config.version; final mod = SimpleModule(Logic(), Logic()); diff --git a/test/counter_test.dart b/test/counter_test.dart index 8f59b58d7..8ce74caad 100644 --- a/test/counter_test.dart +++ b/test/counter_test.dart @@ -48,7 +48,7 @@ void main() { final reset = Logic(); final counter = Counter(Logic(), reset); await counter.build(); - // WaveDumper(counter); + // WaveformService(counter); unawaited(reset.nextPosedge .then((value) => expect(counter.val.value.toInt(), equals(0)))); diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart index 7374a2239..fc2a3fee0 100644 --- a/test/fst_writer_test.dart +++ b/test/fst_writer_test.dart @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause // // fst_writer_test.dart -// Tests for FST writer and WaveDumper FST format support. +// Tests for FST writer and WaveformService FST format support. // // 2026 February // Author: Desmond Kirkpatrick @@ -41,11 +41,15 @@ const _tempDumpDir = 'tmp_test'; /// Gets the path of the FST file based on a name. String _temporaryFstPath(String name) => '$_tempDumpDir/temp_dump_$name.fst'; -/// Attaches a [WaveDumper] to [module] with FST format. +/// Attaches a [WaveformService] to [module] with FST format. void _createFstDump(Module module, String name) { Directory(_tempDumpDir).createSync(recursive: true); final tmpDumpFile = _temporaryFstPath(name); - WaveDumper(module, outputPath: tmpDumpFile, format: WaveFormat.fst); + WaveformService( + module, + outputPath: tmpDumpFile, + format: WaveOutputFormat.fst, + ); } /// Deletes the temporary FST file associated with [name]. @@ -209,7 +213,7 @@ void main() { }); }); - group('WaveDumper FST format', () { + group('WaveformService FST format', () { test('basic 1-bit signal FST dump', () async { final a = Logic(name: 'a'); final mod = _SimpleModule(a); @@ -279,7 +283,11 @@ void main() { const dir1Path = '$_tempDumpDir/fst_dir1'; const fstPath = '$dir1Path/dir2/waves.fst'; - WaveDumper(mod, outputPath: fstPath, format: WaveFormat.fst); + WaveformService( + mod, + outputPath: fstPath, + format: WaveOutputFormat.fst, + ); a.put(0); Simulator.setMaxSimTime(10); @@ -347,7 +355,7 @@ void main() { const vcdPath = '$_tempDumpDir/temp_dump_vcdCompare.vcd'; Directory(_tempDumpDir).createSync(recursive: true); - WaveDumper(mod2, outputPath: vcdPath); + WaveformService(mod2, outputPath: vcdPath); a2.put(0); Simulator.setMaxSimTime(50); diff --git a/test/wave_dumper_test.dart b/test/wave_dumper_test.dart index 07aafc8c8..67f4dedbd 100644 --- a/test/wave_dumper_test.dart +++ b/test/wave_dumper_test.dart @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause // // wave_dumper_test.dart -// Tests for the WaveDumper +// Tests for WaveformService VCD output // // 2021 November 4 // Author: Max Korbel @@ -40,11 +40,11 @@ const tempDumpDir = 'tmp_test'; /// Gets the path of the VCD file based on a name. String temporaryDumpPath(String name) => '$tempDumpDir/temp_dump_$name.vcd'; -/// Attaches a [WaveDumper] to [module] to VCD with [name]. +/// Attaches a [WaveformService] to [module] to VCD with [name]. void createTemporaryDump(Module module, String name) { Directory(tempDumpDir).createSync(recursive: true); final tmpDumpFile = temporaryDumpPath(name); - WaveDumper(module, outputPath: tmpDumpFile); + WaveformService(module, outputPath: tmpDumpFile); } /// Deletes the temporary VCD file associated with [name]. @@ -58,7 +58,7 @@ void main() { await Simulator.reset(); }); - test('attach dumper after put', () async { + test('attach service after put', () async { final a = Logic(name: 'a'); final mod = SimpleModule(a); await mod.build(); @@ -86,7 +86,7 @@ void main() { deleteTemporaryDump(dumpName); }); - test('attach dumper before put', () async { + test('attach service before put', () async { final a = Logic(name: 'a'); final mod = SimpleModule(a); await mod.build(); @@ -241,11 +241,14 @@ void main() { const dir1Path = '$tempDumpDir/dir1'; - final waveDumper = WaveDumper(mod, outputPath: '$dir1Path/dir2/waves.vcd'); + final waveformService = + WaveformService(mod, outputPath: '$dir1Path/dir2/waves.vcd'); - expect(File(waveDumper.outputPath).existsSync(), equals(true)); + expect(File(waveformService.outputPath).existsSync(), equals(true)); - if (File(waveDumper.outputPath).existsSync()) { + await Simulator.run(); + + if (File(waveformService.outputPath).existsSync()) { File(dir1Path).deleteSync(recursive: true); } }); @@ -263,7 +266,7 @@ void main() { Simulator.registerAction(13, () => reset.put(1)); reset.put(0); - // add wave dumper *after* the put to reset + // add waveform service *after* the put to reset createTemporaryDump(mod, dumpName); // check functional matches diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart new file mode 100644 index 000000000..2a9a17b1e --- /dev/null +++ b/test/waveform_service_test.dart @@ -0,0 +1,485 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_service_test.dart +// Tests for WaveformService output and VCD/FST event parity. +// +// 2026 July 17 +// Author: Desmond Kirkpatrick + +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/vcd_parser.dart'; +import 'package:test/test.dart'; + +class _SimpleWaveModule extends Module { + _SimpleWaveModule(Logic a) { + a = addInput('a', a, width: a.width); + addOutput('b', width: a.width) <= ~a; + } +} + +const _tempDumpDir = 'tmp_test'; + +String _temporaryVcdPath(String name) => '$_tempDumpDir/temp_wave_$name.vcd'; + +String _temporaryFstPath(String name) => '$_tempDumpDir/temp_wave_$name.fst'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + test('registers with ModuleServices by default', () async { + final a = Logic(name: 'a'); + final mod = _SimpleWaveModule(a); + await mod.build(); + + WaveformService(mod); + + final service = ModuleServices.instance.lookup(); + expect(service, isNotNull); + final waveformJson = jsonEncode(service!.toJson()); + expect(waveformJson, contains('"format":"vcd"')); + }); + + test('captures waveform to VCD output path', () async { + final a = Logic(name: 'a'); + final mod = _SimpleWaveModule(a); + await mod.build(); + + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryVcdPath('serviceCapture'); + + WaveformService(mod, outputPath: dumpPath, register: false); + + a.inject(1); + Simulator.registerAction(10, () => a.put(0)); + await Simulator.run(); + + final vcdContents = File(dumpPath).readAsStringSync(); + expect( + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), + equals(true), + ); + expect( + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true), + ); + + File(dumpPath).deleteSync(); + }); + + test('captures waveform to FST format', () async { + final a = Logic(name: 'a'); + final mod = _SimpleWaveModule(a); + await mod.build(); + + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryFstPath('fstCapture'); + + WaveformService( + mod, + outputPath: dumpPath, + format: WaveOutputFormat.fst, + register: false, + ); + + a.inject(1); + Simulator.registerAction(10, () => a.put(0)); + await Simulator.run(); + + final fstFile = File(dumpPath); + expect(fstFile.existsSync(), isTrue); + expect(fstFile.lengthSync(), greaterThan(100)); + + fstFile.deleteSync(); + }); + + test('VCD and FST contain matching value-change events', () async { + final vcdPath = _temporaryVcdPath('parity'); + final fstPath = _temporaryFstPath('parity'); + + await _dumpParityWaveform(vcdPath, WaveOutputFormat.vcd); + final vcdEvents = _readVcdEvents(vcdPath, const {'a', 'b'}); + + await Simulator.reset(); + ModuleServices.instance.reset(); + + await _dumpParityWaveform(fstPath, WaveOutputFormat.fst); + final fstEvents = _readFstEvents( + fstPath, + signalNames: const ['a', 'b'], + signalWidths: const [4, 4], + ); + + expect(fstEvents, equals(vcdEvents)); + + File(vcdPath).deleteSync(); + File(fstPath).deleteSync(); + }); +} + +Future _dumpParityWaveform( + String outputPath, WaveOutputFormat format) async { + Directory(_tempDumpDir).createSync(recursive: true); + + final a = Logic(name: 'a', width: 4); + final mod = _SimpleWaveModule(a); + await mod.build(); + + a.put(0x1); + WaveformService( + mod, + outputPath: outputPath, + format: format, + register: false, + ); + + Simulator.registerAction(10, () => a.put(0x2)); + Simulator.registerAction(20, () => a.put(0xf)); + await Simulator.run(); +} + +Map> _readVcdEvents( + String path, + Set signalNames, +) { + final lines = File(path).readAsLinesSync(); + final markerToSignal = {}; + final markerToWidth = {}; + final events = >{ + for (final name in signalNames) name: {}, + }; + + final sigNameRegexp = RegExp( + r'\s*\$var\s(wire|reg)\s(\d+)\s(\S*)\s(\S*)\s+(\[\d+\:\d+\])?\s*\$end', + ); + var currentTime = 0; + var inValues = false; + + for (final line in lines) { + final match = sigNameRegexp.firstMatch(line); + if (match != null) { + final width = int.parse(match.group(2)!); + final marker = match.group(3)!; + final name = match.group(4)!; + if (signalNames.contains(name)) { + markerToSignal[marker] = name; + markerToWidth[marker] = width; + } + continue; + } + + if (line == r'$dumpvars') { + inValues = true; + continue; + } + if (!inValues) { + continue; + } + if (line == r'$end') { + continue; + } + if (line.startsWith('#')) { + currentTime = int.parse(line.substring(1)); + continue; + } + + final parsed = _parseVcdValueUpdate(line, markerToWidth); + if (parsed == null) { + continue; + } + + final signalName = markerToSignal[parsed.marker]; + if (signalName != null) { + events[signalName]![currentTime] = parsed.value; + } + } + + return events; +} + +({String marker, String value})? _parseVcdValueUpdate( + String line, + Map markerToWidth, +) { + if (line.startsWith('b')) { + final parts = line.split(' '); + if (parts.length != 2 || !markerToWidth.containsKey(parts[1])) { + return null; + } + return (marker: parts[1], value: parts[0].substring(1)); + } + + for (final marker in markerToWidth.keys) { + if (line.endsWith(marker)) { + return (marker: marker, value: line[0]); + } + } + return null; +} + +Map> _readFstEvents( + String path, { + required List signalNames, + required List signalWidths, +}) { + final data = File(path).readAsBytesSync(); + final events = >{ + for (final name in signalNames) name: {}, + }; + + var blockOffset = 0; + while (blockOffset < data.length) { + final blockType = data[blockOffset]; + final sectionLength = _readU64(data, blockOffset + 1); + final blockEnd = blockOffset + 1 + sectionLength; + + if (blockType == 8) { + _readFstVcDataBlock( + data, + blockOffset, + blockEnd, + signalNames: signalNames, + signalWidths: signalWidths, + events: events, + ); + } + + blockOffset = blockEnd; + } + + return events; +} + +void _readFstVcDataBlock( + Uint8List data, + int blockOffset, + int blockEnd, { + required List signalNames, + required List signalWidths, + required Map> events, +}) { + final startTime = _readU64(data, blockOffset + 9); + var offset = blockOffset + 33; + + final frameUncompressed = _readVarint(data, offset); + offset = frameUncompressed.next; + final frameCompressed = _readVarint(data, offset); + offset = frameCompressed.next; + final maxHandle = _readVarint(data, offset); + offset = maxHandle.next; + + final frameBytes = _inflateIfNeeded( + data.sublist(offset, offset + frameCompressed.value), + frameUncompressed.value, + ); + offset += frameCompressed.value; + + var frameOffset = 0; + for (var i = 0; i < signalNames.length; i++) { + final width = signalWidths[i]; + final value = String.fromCharCodes( + frameBytes.sublist(frameOffset, frameOffset + width)); + frameOffset += width; + events[signalNames[i]]![startTime] = value; + } + + final valueMaxHandle = _readVarint(data, offset); + offset = valueMaxHandle.next; + final valueSectionStart = offset; + offset++; // pack_type + + final timeCount = _readU64(data, blockEnd - 8); + final timeCompressedLength = _readU64(data, blockEnd - 16); + final timeUncompressedLength = _readU64(data, blockEnd - 24); + final timeDataStart = blockEnd - 24 - timeCompressedLength; + final timeBytes = _inflateIfNeeded( + data.sublist(timeDataStart, timeDataStart + timeCompressedLength), + timeUncompressedLength, + ); + final timeTable = _decodeTimeTable(timeBytes, timeCount); + + final chainLength = _readU64(data, timeDataStart - 8); + final chainStart = timeDataStart - 8 - chainLength; + final signalOffsets = _decodeFstOffsetChain( + data.sublist(chainStart, timeDataStart - 8), + valueMaxHandle.value, + ); + + for (var signalIndex = 0; signalIndex < signalNames.length; signalIndex++) { + final signalOffset = signalOffsets[signalIndex]; + if (signalOffset == null) { + continue; + } + + final nextOffset = signalOffsets + .skip(signalIndex + 1) + .whereType() + .cast() + .firstWhere((offset) => offset != null, orElse: () => null); + final signalDataStart = valueSectionStart + signalOffset; + final signalDataEnd = + nextOffset == null ? chainStart : valueSectionStart + nextOffset; + _decodeFstSignalData( + data.sublist(signalDataStart, signalDataEnd), + width: signalWidths[signalIndex], + signalName: signalNames[signalIndex], + timeTable: timeTable, + events: events, + ); + } +} + +List _decodeTimeTable(Uint8List bytes, int count) { + final times = []; + var offset = 0; + var previousTime = 0; + for (var i = 0; i < count; i++) { + final delta = _readVarint(bytes, offset); + offset = delta.next; + previousTime += delta.value; + times.add(previousTime); + } + return times; +} + +List _decodeFstOffsetChain(Uint8List bytes, int maxHandle) { + final offsets = List.filled(maxHandle, null); + var byteOffset = 0; + var signalIndex = 0; + var previousOffset = 0; + + while (signalIndex < maxHandle && byteOffset < bytes.length) { + final encoded = _readSignedVarint(bytes, byteOffset); + byteOffset = encoded.next; + if (encoded.value.isEven) { + signalIndex += encoded.value >> 1; + } else { + previousOffset += encoded.value >> 1; + offsets[signalIndex] = previousOffset; + signalIndex++; + } + } + + return offsets; +} + +void _decodeFstSignalData( + Uint8List bytes, { + required int width, + required String signalName, + required List timeTable, + required Map> events, +}) { + var offset = 0; + final compression = _readVarint(bytes, offset); + offset = compression.next; + expect(compression.value, equals(0), + reason: 'Only uncompressed signal chains are expected'); + + var timeIndex = 0; + while (offset < bytes.length) { + if (width == 1) { + final encoded = _readVarint(bytes, offset); + offset = encoded.next; + String value; + int timeDelta; + if (encoded.value.isEven) { + value = ((encoded.value >> 1) & 1).toString(); + timeDelta = encoded.value >> 2; + } else { + const rcvChars = 'xzhuwl-?'; + value = rcvChars[(encoded.value >> 1) & 0x7]; + timeDelta = encoded.value >> 4; + } + timeIndex += timeDelta; + events[signalName]![timeTable[timeIndex]] = value; + } else { + final encoded = _readVarint(bytes, offset); + offset = encoded.next; + timeIndex += encoded.value >> 1; + + final isFourState = encoded.value.isOdd; + String value; + if (isFourState) { + value = String.fromCharCodes(bytes.sublist(offset, offset + width)); + offset += width; + } else { + final byteCount = (width + 7) ~/ 8; + final packed = bytes.sublist(offset, offset + byteCount); + offset += byteCount; + value = _unpackTwoStateBits(packed, width); + } + + events[signalName]![timeTable[timeIndex]] = value; + } + } +} + +String _unpackTwoStateBits(Uint8List bytes, int width) { + final bits = StringBuffer(); + for (var i = 0; i < width; i++) { + final byteIndex = i ~/ 8; + final bitIndex = 7 - (i % 8); + bits.write(((bytes[byteIndex] >> bitIndex) & 1).toString()); + } + return bits.toString(); +} + +Uint8List _inflateIfNeeded(Uint8List bytes, int uncompressedLength) { + if (bytes.length == uncompressedLength) { + return bytes; + } + return Uint8List.fromList(ZLibCodec().decode(bytes)); +} + +({int value, int next}) _readVarint(Uint8List data, int offset) { + var value = 0; + var shift = 0; + var next = offset; + + while (true) { + final byte = data[next++]; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) == 0) { + return (value: value, next: next); + } + shift += 7; + } +} + +({int value, int next}) _readSignedVarint(Uint8List data, int offset) { + var value = 0; + var shift = 0; + var next = offset; + late int byte; + + do { + byte = data[next++]; + value |= (byte & 0x7f) << shift; + shift += 7; + } while ((byte & 0x80) != 0); + + if (shift < 64 && (byte & 0x40) != 0) { + value |= -(1 << shift); + } + + return (value: value, next: next); +} + +int _readU64(Uint8List data, int offset) { + var result = 0; + for (var i = 0; i < 8; i++) { + result = (result << 8) | data[offset + i]; + } + return result; +} From 6a39eea4e17bfe70dd27a373fd77a72f48d6ef38 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Jul 2026 13:16:16 -0700 Subject: [PATCH 11/11] cleanup vcd from waveform_service_test.dart --- test/waveform_service_test.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index 2a9a17b1e..3a321f19c 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -42,12 +42,17 @@ void main() { final mod = _SimpleWaveModule(a); await mod.build(); - WaveformService(mod); + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryVcdPath('serviceRegistration'); + + WaveformService(mod, outputPath: dumpPath); final service = ModuleServices.instance.lookup(); expect(service, isNotNull); final waveformJson = jsonEncode(service!.toJson()); expect(waveformJson, contains('"format":"vcd"')); + + File(dumpPath).deleteSync(); }); test('captures waveform to VCD output path', () async {